1773 lines
66 KiB
TypeScript
1773 lines
66 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
import {
|
|
Palette,
|
|
Grid3X3,
|
|
Type,
|
|
Calendar,
|
|
Hash,
|
|
CheckSquare,
|
|
Radio,
|
|
Save,
|
|
Undo,
|
|
Redo,
|
|
Group,
|
|
Database,
|
|
Trash2,
|
|
Settings,
|
|
ChevronDown,
|
|
Code,
|
|
Building,
|
|
File,
|
|
List,
|
|
AlignLeft,
|
|
ChevronRight,
|
|
Copy,
|
|
Clipboard,
|
|
} from "lucide-react";
|
|
import {
|
|
ScreenDefinition,
|
|
ComponentData,
|
|
LayoutData,
|
|
GroupState,
|
|
WebType,
|
|
TableInfo,
|
|
GroupComponent,
|
|
} from "@/types/screen";
|
|
import { generateComponentId } from "@/lib/utils/generateId";
|
|
import {
|
|
createGroupComponent,
|
|
calculateBoundingBox,
|
|
calculateRelativePositions,
|
|
restoreAbsolutePositions,
|
|
getGroupChildren,
|
|
} from "@/lib/utils/groupingUtils";
|
|
import { GroupingToolbar } from "./GroupingToolbar";
|
|
|
|
import StyleEditor from "./StyleEditor";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { RealtimePreview } from "./RealtimePreview";
|
|
|
|
interface ScreenDesignerProps {
|
|
selectedScreen: ScreenDefinition | null;
|
|
onBackToList: () => void;
|
|
}
|
|
|
|
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
|
|
const [layout, setLayout] = useState<LayoutData>({
|
|
components: [],
|
|
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
|
});
|
|
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
|
|
|
|
// 실행취소/다시실행을 위한 히스토리 상태
|
|
const [history, setHistory] = useState<LayoutData[]>([
|
|
{
|
|
components: [],
|
|
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
|
},
|
|
]);
|
|
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));
|
|
},
|
|
[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 [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 fetchTables = async () => {
|
|
try {
|
|
const response = await fetch("http://localhost:8080/api/screen-management/tables", {
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setTables(data.data);
|
|
} else {
|
|
console.error("테이블 조회 실패:", data.message);
|
|
// 임시 데이터로 폴백
|
|
setTables(getMockTables());
|
|
}
|
|
} else {
|
|
console.error("테이블 조회 실패:", response.status);
|
|
// 임시 데이터로 폴백
|
|
setTables(getMockTables());
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 조회 중 오류:", error);
|
|
// 임시 데이터로 폴백
|
|
setTables(getMockTables());
|
|
}
|
|
};
|
|
|
|
fetchTables();
|
|
}, []);
|
|
|
|
// 검색된 테이블 필터링
|
|
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 실패 시 사용)
|
|
const getMockTables = (): TableInfo[] => [
|
|
{
|
|
tableName: "user_info",
|
|
tableLabel: "사용자 정보",
|
|
columns: [
|
|
{
|
|
tableName: "user_info",
|
|
columnName: "user_id",
|
|
columnLabel: "사용자 ID",
|
|
webType: "text",
|
|
dataType: "VARCHAR",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "user_info",
|
|
columnName: "user_name",
|
|
columnLabel: "사용자명",
|
|
webType: "text",
|
|
dataType: "VARCHAR",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "user_info",
|
|
columnName: "email",
|
|
columnLabel: "이메일",
|
|
webType: "email",
|
|
dataType: "VARCHAR",
|
|
isNullable: "YES",
|
|
},
|
|
{
|
|
tableName: "user_info",
|
|
columnName: "phone",
|
|
columnLabel: "전화번호",
|
|
webType: "tel",
|
|
dataType: "VARCHAR",
|
|
isNullable: "YES",
|
|
},
|
|
{
|
|
tableName: "user_info",
|
|
columnName: "birth_date",
|
|
columnLabel: "생년월일",
|
|
webType: "date",
|
|
dataType: "DATE",
|
|
isNullable: "YES",
|
|
},
|
|
{
|
|
tableName: "user_info",
|
|
columnName: "is_active",
|
|
columnLabel: "활성화",
|
|
webType: "checkbox",
|
|
dataType: "BOOLEAN",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "user_info",
|
|
columnName: "profile_code",
|
|
columnLabel: "프로필 코드",
|
|
webType: "code",
|
|
dataType: "TEXT",
|
|
isNullable: "YES",
|
|
},
|
|
{
|
|
tableName: "user_info",
|
|
columnName: "department",
|
|
columnLabel: "부서",
|
|
webType: "entity",
|
|
dataType: "VARCHAR",
|
|
isNullable: "YES",
|
|
},
|
|
{
|
|
tableName: "user_info",
|
|
columnName: "profile_image",
|
|
columnLabel: "프로필 이미지",
|
|
webType: "file",
|
|
dataType: "VARCHAR",
|
|
isNullable: "YES",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
tableName: "product_info",
|
|
tableLabel: "제품 정보",
|
|
columns: [
|
|
{
|
|
tableName: "product_info",
|
|
columnName: "product_id",
|
|
columnLabel: "제품 ID",
|
|
webType: "text",
|
|
dataType: "VARCHAR",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "product_info",
|
|
columnName: "product_name",
|
|
columnLabel: "제품명",
|
|
webType: "text",
|
|
dataType: "VARCHAR",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "product_info",
|
|
columnName: "category",
|
|
columnLabel: "카테고리",
|
|
webType: "select",
|
|
dataType: "VARCHAR",
|
|
isNullable: "YES",
|
|
},
|
|
{
|
|
tableName: "product_info",
|
|
columnName: "price",
|
|
columnLabel: "가격",
|
|
webType: "number",
|
|
dataType: "DECIMAL",
|
|
isNullable: "YES",
|
|
},
|
|
{
|
|
tableName: "product_info",
|
|
columnName: "description",
|
|
columnLabel: "설명",
|
|
webType: "textarea",
|
|
dataType: "TEXT",
|
|
isNullable: "YES",
|
|
},
|
|
{
|
|
tableName: "product_info",
|
|
columnName: "created_date",
|
|
columnLabel: "생성일",
|
|
webType: "date",
|
|
dataType: "TIMESTAMP",
|
|
isNullable: "NO",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
tableName: "order_info",
|
|
tableLabel: "주문 정보",
|
|
columns: [
|
|
{
|
|
tableName: "order_info",
|
|
columnName: "order_id",
|
|
columnLabel: "주문 ID",
|
|
webType: "text",
|
|
dataType: "VARCHAR",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "order_info",
|
|
columnName: "customer_name",
|
|
columnLabel: "고객명",
|
|
webType: "text",
|
|
dataType: "VARCHAR",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "order_info",
|
|
columnName: "order_date",
|
|
columnLabel: "주문일",
|
|
webType: "date",
|
|
dataType: "DATE",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "order_info",
|
|
columnName: "total_amount",
|
|
columnLabel: "총 금액",
|
|
webType: "number",
|
|
dataType: "DECIMAL",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "order_info",
|
|
columnName: "status",
|
|
columnLabel: "상태",
|
|
webType: "select",
|
|
dataType: "VARCHAR",
|
|
isNullable: "NO",
|
|
},
|
|
{
|
|
tableName: "order_info",
|
|
columnName: "notes",
|
|
columnLabel: "비고",
|
|
webType: "textarea",
|
|
dataType: "TEXT",
|
|
isNullable: "YES",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
// 테이블 확장/축소 토글
|
|
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;
|
|
}
|
|
} else if (e.key === "Delete") {
|
|
e.preventDefault();
|
|
// 선택된 컴포넌트(들) 삭제
|
|
deleteComponents();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [undo, redo, copyComponents, pasteComponents, deleteComponents, clipboard]);
|
|
|
|
// 컴포넌트 속성 업데이트 함수
|
|
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;
|
|
|
|
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],
|
|
);
|
|
|
|
// 그룹 생성 함수
|
|
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 () => {
|
|
try {
|
|
// TODO: 실제 API 호출로 변경
|
|
console.log("레이아웃 저장:", layout);
|
|
// await saveLayoutAPI(selectedScreen.screenId, layout);
|
|
} catch (error) {
|
|
console.error("레이아웃 저장 실패:", error);
|
|
}
|
|
}, [layout, selectedScreen]);
|
|
|
|
// 캔버스 참조 (좌표 계산 정확도 향상)
|
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
|
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)) {
|
|
return {
|
|
...comp,
|
|
position: {
|
|
x: comp.position.x + deltaX,
|
|
y: comp.position.y + deltaY,
|
|
},
|
|
};
|
|
}
|
|
return comp;
|
|
}),
|
|
};
|
|
setLayout(newLayout);
|
|
saveToHistory(newLayout);
|
|
} else {
|
|
// 단일 드래그 처리
|
|
const x = mouseX - dragState.grabOffset.x;
|
|
const y = mouseY - dragState.grabOffset.y;
|
|
const newLayout = {
|
|
...layout,
|
|
components: layout.components.map((comp) =>
|
|
comp.id === data.id ? { ...comp, position: { x, y } } : comp,
|
|
),
|
|
};
|
|
setLayout(newLayout);
|
|
saveToHistory(newLayout);
|
|
}
|
|
} else {
|
|
// 새 컴포넌트 추가
|
|
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;
|
|
|
|
const newComponent: ComponentData = {
|
|
...data,
|
|
id: generateComponentId(),
|
|
position: { x, y },
|
|
} 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,
|
|
],
|
|
);
|
|
|
|
// 드래그 종료
|
|
const endDrag = useCallback(() => {
|
|
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 },
|
|
});
|
|
}, []);
|
|
|
|
// 컴포넌트 클릭 (선택)
|
|
const handleComponentClick = useCallback(
|
|
(component: ComponentData, event?: React.MouseEvent) => {
|
|
const isShiftPressed = event?.shiftKey || false;
|
|
|
|
// 그룹 컨테이너는 다중선택 대상에서 제외
|
|
const isGroupContainer = component.type === "group";
|
|
|
|
if (groupState.isGrouping || isShiftPressed) {
|
|
// 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택
|
|
if (isGroupContainer) {
|
|
// 그룹 컨테이너 클릭은 다중선택에 포함하지 않고 무시
|
|
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: isGroupContainer ? [] : [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-full w-full flex-col">
|
|
{/* 상단 헤더 */}
|
|
<div className="flex items-center justify-between border-b bg-white p-4 shadow-sm">
|
|
<div className="flex items-center space-x-4">
|
|
<h2 className="text-xl font-semibold text-gray-900">{selectedScreen.screenName} - 화면 설계</h2>
|
|
<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} className="bg-blue-600 hover:bg-blue-700">
|
|
<Save className="mr-2 h-4 w-4" />
|
|
저장
|
|
</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);
|
|
}}
|
|
/>
|
|
|
|
{/* 메인 컨텐츠 영역 */}
|
|
<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">
|
|
<h3 className="mb-4 text-lg font-medium">테이블 타입</h3>
|
|
|
|
{/* 검색 입력창 */}
|
|
<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.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}
|
|
>
|
|
{layout.components.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="text-center text-gray-500">
|
|
<Grid3X3 className="mx-auto mb-4 h-16 w-16 text-gray-300" />
|
|
<p className="mb-2 text-lg font-medium">빈 캔버스</p>
|
|
<p className="text-sm">좌측에서 테이블이나 컬럼을 드래그하여 배치하세요</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="relative min-h-[600px]">
|
|
{/* 그리드 가이드 */}
|
|
<div className="pointer-events-none absolute inset-0">
|
|
<div className="grid h-full grid-cols-12 gap-1">
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<div key={i} className="border-r border-gray-200 last:border-r-0" />
|
|
))}
|
|
</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 p-4">
|
|
<h3 className="mb-4 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)) {
|
|
updateComponentProperty(selectedComponent.id, "position.x", Math.round(val));
|
|
}
|
|
}}
|
|
/>
|
|
</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)) {
|
|
updateComponentProperty(selectedComponent.id, "position.y", Math.round(val));
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 크기 속성 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="width">너비 (컬럼)</Label>
|
|
<Input
|
|
id="width"
|
|
type="number"
|
|
min="20"
|
|
value={selectedComponent.size.width}
|
|
onChange={(e) => {
|
|
const val = (e.target as HTMLInputElement).valueAsNumber;
|
|
if (Number.isFinite(val)) {
|
|
updateComponentProperty(
|
|
selectedComponent.id,
|
|
"size.width",
|
|
Math.max(20, Math.round(val)),
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
</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)) {
|
|
updateComponentProperty(
|
|
selectedComponent.id,
|
|
"size.height",
|
|
Math.max(20, Math.round(val)),
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|