다중선택 및 정렬기능 구현
This commit is contained in:
parent
e18c78f40d
commit
984dd70505
|
|
@ -14,7 +14,19 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Group, Ungroup, Palette, Settings, X, Check } from "lucide-react";
|
||||
import {
|
||||
Group,
|
||||
Ungroup,
|
||||
Palette,
|
||||
Settings,
|
||||
X,
|
||||
Check,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
StretchHorizontal,
|
||||
StretchVertical,
|
||||
} from "lucide-react";
|
||||
import { GroupState, ComponentData, ComponentStyle } from "@/types/screen";
|
||||
import { createGroupStyle } from "@/lib/utils/groupingUtils";
|
||||
|
||||
|
|
@ -25,6 +37,8 @@ interface GroupingToolbarProps {
|
|||
onGroupUngroup: (groupId: string) => void;
|
||||
selectedComponents: ComponentData[];
|
||||
allComponents: ComponentData[];
|
||||
onGroupAlign?: (mode: "left" | "centerX" | "right" | "top" | "centerY" | "bottom") => void;
|
||||
onGroupDistribute?: (orientation: "horizontal" | "vertical") => void;
|
||||
}
|
||||
|
||||
export const GroupingToolbar: React.FC<GroupingToolbarProps> = ({
|
||||
|
|
@ -34,6 +48,8 @@ export const GroupingToolbar: React.FC<GroupingToolbarProps> = ({
|
|||
onGroupUngroup,
|
||||
selectedComponents,
|
||||
allComponents,
|
||||
onGroupAlign,
|
||||
onGroupDistribute,
|
||||
}) => {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [groupTitle, setGroupTitle] = useState("새 그룹");
|
||||
|
|
@ -102,6 +118,9 @@ export const GroupingToolbar: React.FC<GroupingToolbarProps> = ({
|
|||
{selectedComponents.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{selectedComponents.length}개 선택됨
|
||||
{selectedComponents.length > 1 && (
|
||||
<span className="ml-1 text-xs opacity-75">(Shift+클릭으로 다중선택, 드래그로 함께 이동)</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
|
|
@ -147,6 +166,49 @@ export const GroupingToolbar: React.FC<GroupingToolbarProps> = ({
|
|||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 정렬/분배 도구 */}
|
||||
{selectedComponents.length > 1 && (
|
||||
<div className="ml-2 flex items-center space-x-1">
|
||||
<span className="mr-1 text-xs text-gray-500">정렬</span>
|
||||
<Button variant="outline" size="sm" onClick={() => onGroupAlign?.("left")} title="좌측 정렬">
|
||||
<AlignLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onGroupAlign?.("centerX")} title="가로 중앙 정렬">
|
||||
<AlignCenter className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onGroupAlign?.("right")} title="우측 정렬">
|
||||
<AlignRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onGroupAlign?.("top")} title="상단 정렬">
|
||||
<AlignLeft className="h-3 w-3 rotate-90" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onGroupAlign?.("centerY")} title="세로 중앙 정렬">
|
||||
<AlignCenter className="h-3 w-3 rotate-90" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onGroupAlign?.("bottom")} title="하단 정렬">
|
||||
<AlignRight className="h-3 w-3 rotate-90" />
|
||||
</Button>
|
||||
<div className="mx-1 h-4 w-px bg-gray-200" />
|
||||
<span className="mr-1 text-xs text-gray-500">균등</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onGroupDistribute?.("horizontal")}
|
||||
title="가로 균등 분배"
|
||||
>
|
||||
<StretchHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onGroupDistribute?.("vertical")}
|
||||
title="세로 균등 분배"
|
||||
>
|
||||
<StretchVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
interface RealtimePreviewProps {
|
||||
component: ComponentData;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: () => void;
|
||||
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
||||
|
|
@ -216,11 +216,14 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|||
style={{
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: `${size.width * 80}px`,
|
||||
width: `${size.width}px`, // 격자 기반 계산 제거
|
||||
height: `${size.height}px`,
|
||||
...style,
|
||||
}}
|
||||
onClick={onClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
@ -242,34 +245,9 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|||
)}
|
||||
|
||||
{type === "group" && (
|
||||
<div className="flex h-full flex-col rounded-lg border border-gray-200 bg-gray-50">
|
||||
{/* 그룹 헤더 */}
|
||||
<div
|
||||
className="pointer-events-auto flex cursor-pointer items-center justify-between rounded-t-lg border-b bg-white px-2 py-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onGroupToggle?.(component.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Group className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-xs font-medium">{label || "그룹"}</span>
|
||||
<span className="text-xs text-gray-500">({children ? children.length : 0}개)</span>
|
||||
</div>
|
||||
{component.collapsible &&
|
||||
(component.collapsed ? (
|
||||
<ChevronRight className="h-3 w-3 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-gray-500" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 그룹 내용 */}
|
||||
{!component.collapsed && (
|
||||
<div className="pointer-events-none flex-1 space-y-1 overflow-auto p-1">
|
||||
{children ? children : <div className="py-2 text-center text-xs text-gray-400">그룹이 비어있습니다</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative h-full w-full">
|
||||
{/* 그룹 박스/헤더 제거: 투명 컨테이너 */}
|
||||
<div className="absolute inset-0">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
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";
|
||||
|
|
@ -142,8 +142,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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,
|
||||
|
|
@ -160,6 +164,92 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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 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 () => {
|
||||
|
|
@ -614,37 +704,100 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
}, [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: 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) => {
|
||||
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: 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 = e.currentTarget.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / 80) * 80;
|
||||
const y = Math.floor((e.clientY - rect.top) / 60) * 60;
|
||||
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,
|
||||
|
|
@ -665,21 +818,59 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
if (data.isMoving) {
|
||||
// 기존 컴포넌트 재배치
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / 80) * 80;
|
||||
const y = Math.floor((e.clientY - rect.top) / 60) * 60;
|
||||
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) => (comp.id === data.id ? { ...comp, position: { x, y } } : comp)),
|
||||
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 = e.currentTarget.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / 80) * 80;
|
||||
const y = Math.floor((e.clientY - rect.top) / 60) * 60;
|
||||
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,
|
||||
|
|
@ -701,11 +892,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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],
|
||||
[
|
||||
layout,
|
||||
saveToHistory,
|
||||
dragState.initialMouse.x,
|
||||
dragState.initialMouse.y,
|
||||
dragState.grabOffset.x,
|
||||
dragState.grabOffset.y,
|
||||
],
|
||||
);
|
||||
|
||||
// 드래그 종료
|
||||
|
|
@ -713,16 +915,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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) => {
|
||||
if (groupState.isGrouping) {
|
||||
// 그룹화 모드에서는 다중 선택
|
||||
(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,
|
||||
|
|
@ -730,16 +945,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
? 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],
|
||||
selectedComponents: isGroupContainer ? [] : [component.id],
|
||||
}));
|
||||
}
|
||||
},
|
||||
[groupState.isGrouping],
|
||||
[groupState.isGrouping, groupState.selectedComponents],
|
||||
);
|
||||
|
||||
// 화면이 선택되지 않았을 때 처리
|
||||
|
|
@ -773,7 +993,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
variant={groupState.isGrouping ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setGroupState((prev) => ({ ...prev, isGrouping: !prev.isGrouping }))}
|
||||
title="그룹화 모드 토글"
|
||||
title="그룹화 모드 토글 (일반 모드에서도 Shift+클릭으로 다중선택 가능)"
|
||||
>
|
||||
<Group className="mr-2 h-4 w-4" />
|
||||
{groupState.isGrouping ? "그룹화 모드" : "일반 모드"}
|
||||
|
|
@ -807,6 +1027,75 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 메인 컨텐츠 영역 */}
|
||||
|
|
@ -850,7 +1139,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
type: "container",
|
||||
tableName: table.tableName,
|
||||
label: table.tableLabel,
|
||||
size: { width: 12, height: 80 },
|
||||
size: { width: 200, height: 80 }, // 픽셀 단위로 변경
|
||||
},
|
||||
e,
|
||||
)
|
||||
|
|
@ -896,7 +1185,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
columnName: column.columnName,
|
||||
widgetType: widgetType as WebType,
|
||||
label: column.columnLabel || column.columnName,
|
||||
size: { width: 6, height: 40 },
|
||||
size: { width: 150, height: 40 }, // 픽셀 단위로 변경
|
||||
},
|
||||
e,
|
||||
);
|
||||
|
|
@ -964,12 +1253,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
</div>
|
||||
|
||||
{/* 중앙: 캔버스 영역 */}
|
||||
<div className="flex-1 bg-white">
|
||||
<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}
|
||||
>
|
||||
{layout.components.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -990,6 +1283,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
</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) // 최상위 컴포넌트만 렌더링
|
||||
|
|
@ -1008,7 +1314,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
selectedComponent?.id === component.id ||
|
||||
groupState.selectedComponents.includes(component.id)
|
||||
}
|
||||
onClick={() => handleComponentClick(component)}
|
||||
onClick={(e) => handleComponentClick(component, e)}
|
||||
onDragStart={(e) => startComponentDrag(component, e)}
|
||||
onDragEnd={endDrag}
|
||||
onGroupToggle={(groupId) => {
|
||||
|
|
@ -1022,7 +1328,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
key={child.id}
|
||||
component={child}
|
||||
isSelected={groupState.selectedComponents.includes(child.id)}
|
||||
onClick={() => handleComponentClick(child)}
|
||||
onClick={(e) => handleComponentClick(child, e)}
|
||||
onDragStart={(e) => startComponentDrag(child, e)}
|
||||
onDragEnd={endDrag}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,15 +15,15 @@ export function createGroupComponent(
|
|||
boundingBox?: { width: number; height: number },
|
||||
style?: any,
|
||||
): GroupComponent {
|
||||
// 격자 기반 크기 계산
|
||||
const gridWidth = Math.max(6, Math.ceil(boundingBox?.width / 80) + 2); // 최소 6 그리드, 여백 2
|
||||
const gridHeight = Math.max(100, (boundingBox?.height || 200) + 40); // 최소 100px, 여백 40px
|
||||
// 픽셀 기반 크기 계산 (격자 제거)
|
||||
const groupWidth = Math.max(200, (boundingBox?.width || 200) + 40); // 최소 200px, 여백 40px
|
||||
const groupHeight = Math.max(100, (boundingBox?.height || 200) + 40); // 최소 100px, 여백 40px
|
||||
|
||||
return {
|
||||
id: generateComponentId(),
|
||||
type: "group",
|
||||
position,
|
||||
size: { width: gridWidth, height: gridHeight },
|
||||
size: { width: groupWidth, height: groupHeight },
|
||||
label: title, // title 대신 label 사용
|
||||
backgroundColor: "#f8f9fa",
|
||||
border: "1px solid #dee2e6",
|
||||
|
|
@ -39,7 +39,7 @@ export function createGroupComponent(
|
|||
};
|
||||
}
|
||||
|
||||
// 선택된 컴포넌트들의 경계 박스 계산 (격자 기반)
|
||||
// 선택된 컴포넌트들의 경계 박스 계산 (픽셀 기반)
|
||||
export function calculateBoundingBox(components: ComponentData[]): {
|
||||
minX: number;
|
||||
minY: number;
|
||||
|
|
@ -54,7 +54,7 @@ export function calculateBoundingBox(components: ComponentData[]): {
|
|||
|
||||
const minX = Math.min(...components.map((c) => c.position.x));
|
||||
const minY = Math.min(...components.map((c) => c.position.y));
|
||||
const maxX = Math.max(...components.map((c) => c.position.x + c.size.width * 80));
|
||||
const maxX = Math.max(...components.map((c) => c.position.x + c.size.width)); // 격자 계산 제거
|
||||
const maxY = Math.max(...components.map((c) => c.position.y + c.size.height));
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in New Issue