다중선택 및 정렬기능 구현
This commit is contained in:
parent
e18c78f40d
commit
984dd70505
|
|
@ -14,7 +14,19 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { GroupState, ComponentData, ComponentStyle } from "@/types/screen";
|
||||||
import { createGroupStyle } from "@/lib/utils/groupingUtils";
|
import { createGroupStyle } from "@/lib/utils/groupingUtils";
|
||||||
|
|
||||||
|
|
@ -25,6 +37,8 @@ interface GroupingToolbarProps {
|
||||||
onGroupUngroup: (groupId: string) => void;
|
onGroupUngroup: (groupId: string) => void;
|
||||||
selectedComponents: ComponentData[];
|
selectedComponents: ComponentData[];
|
||||||
allComponents: ComponentData[];
|
allComponents: ComponentData[];
|
||||||
|
onGroupAlign?: (mode: "left" | "centerX" | "right" | "top" | "centerY" | "bottom") => void;
|
||||||
|
onGroupDistribute?: (orientation: "horizontal" | "vertical") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GroupingToolbar: React.FC<GroupingToolbarProps> = ({
|
export const GroupingToolbar: React.FC<GroupingToolbarProps> = ({
|
||||||
|
|
@ -34,6 +48,8 @@ export const GroupingToolbar: React.FC<GroupingToolbarProps> = ({
|
||||||
onGroupUngroup,
|
onGroupUngroup,
|
||||||
selectedComponents,
|
selectedComponents,
|
||||||
allComponents,
|
allComponents,
|
||||||
|
onGroupAlign,
|
||||||
|
onGroupDistribute,
|
||||||
}) => {
|
}) => {
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
const [groupTitle, setGroupTitle] = useState("새 그룹");
|
const [groupTitle, setGroupTitle] = useState("새 그룹");
|
||||||
|
|
@ -102,6 +118,9 @@ export const GroupingToolbar: React.FC<GroupingToolbarProps> = ({
|
||||||
{selectedComponents.length > 0 && (
|
{selectedComponents.length > 0 && (
|
||||||
<Badge variant="secondary" className="ml-2">
|
<Badge variant="secondary" className="ml-2">
|
||||||
{selectedComponents.length}개 선택됨
|
{selectedComponents.length}개 선택됨
|
||||||
|
{selectedComponents.length > 1 && (
|
||||||
|
<span className="ml-1 text-xs opacity-75">(Shift+클릭으로 다중선택, 드래그로 함께 이동)</span>
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -147,6 +166,49 @@ export const GroupingToolbar: React.FC<GroupingToolbarProps> = ({
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import {
|
||||||
interface RealtimePreviewProps {
|
interface RealtimePreviewProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: (e?: React.MouseEvent) => void;
|
||||||
onDragStart?: (e: React.DragEvent) => void;
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
||||||
|
|
@ -216,11 +216,14 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
||||||
style={{
|
style={{
|
||||||
left: `${component.position.x}px`,
|
left: `${component.position.x}px`,
|
||||||
top: `${component.position.y}px`,
|
top: `${component.position.y}px`,
|
||||||
width: `${size.width * 80}px`,
|
width: `${size.width}px`, // 격자 기반 계산 제거
|
||||||
height: `${size.height}px`,
|
height: `${size.height}px`,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
onClick={onClick}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(e);
|
||||||
|
}}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
|
|
@ -242,34 +245,9 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === "group" && (
|
{type === "group" && (
|
||||||
<div className="flex h-full flex-col rounded-lg border border-gray-200 bg-gray-50">
|
<div className="relative h-full w-full">
|
||||||
{/* 그룹 헤더 */}
|
{/* 그룹 박스/헤더 제거: 투명 컨테이너 */}
|
||||||
<div
|
<div className="absolute inset-0">{children}</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
@ -142,8 +142,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const [dragState, setDragState] = useState({
|
const [dragState, setDragState] = useState({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
draggedComponent: null as ComponentData | null,
|
draggedComponent: null as ComponentData | null,
|
||||||
|
draggedComponents: [] as ComponentData[], // 다중선택된 컴포넌트들
|
||||||
originalPosition: { x: 0, y: 0 },
|
originalPosition: { x: 0, y: 0 },
|
||||||
currentPosition: { 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>({
|
const [groupState, setGroupState] = useState<GroupState>({
|
||||||
isGrouping: false,
|
isGrouping: false,
|
||||||
|
|
@ -160,6 +164,92 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage] = useState(10);
|
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에서 가져와야 함)
|
// 테이블 데이터 로드 (실제로는 API에서 가져와야 함)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTables = async () => {
|
const fetchTables = async () => {
|
||||||
|
|
@ -614,37 +704,100 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
}, [layout, selectedScreen]);
|
}, [layout, selectedScreen]);
|
||||||
|
|
||||||
|
// 캔버스 참조 (좌표 계산 정확도 향상)
|
||||||
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// 드래그 시작 (새 컴포넌트 추가)
|
// 드래그 시작 (새 컴포넌트 추가)
|
||||||
const startDrag = useCallback((component: Partial<ComponentData>, e: React.DragEvent) => {
|
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({
|
setDragState({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
draggedComponent: component as ComponentData,
|
draggedComponent: component as ComponentData,
|
||||||
|
draggedComponents: [component as ComponentData],
|
||||||
originalPosition: { x: 0, y: 0 },
|
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));
|
e.dataTransfer.setData("application/json", JSON.stringify(component));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 기존 컴포넌트 드래그 시작 (재배치)
|
// 기존 컴포넌트 드래그 시작 (재배치)
|
||||||
const startComponentDrag = useCallback((component: ComponentData, e: React.DragEvent) => {
|
const startComponentDrag = useCallback(
|
||||||
e.stopPropagation();
|
(component: ComponentData, e: React.DragEvent) => {
|
||||||
setDragState({
|
e.stopPropagation();
|
||||||
isDragging: true,
|
|
||||||
draggedComponent: component,
|
// 다중선택된 컴포넌트들이 있는지 확인
|
||||||
originalPosition: component.position,
|
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
|
||||||
currentPosition: component.position,
|
|
||||||
});
|
const isMultiDrag = selectedComponents.length > 1 && groupState.selectedComponents.includes(component.id);
|
||||||
e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true }));
|
|
||||||
}, []);
|
// 마우스-컴포넌트 그랩 오프셋 계산 (커서와 컴포넌트 좌측상단의 거리)
|
||||||
|
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(
|
const onDragOver = useCallback(
|
||||||
(e: React.DragEvent) => {
|
(e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (dragState.isDragging) {
|
if (dragState.isDragging) {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
const x = Math.floor((e.clientX - rect.left) / 80) * 80;
|
// 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준)
|
||||||
const y = Math.floor((e.clientY - rect.top) / 60) * 60;
|
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) => ({
|
setDragState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -665,21 +818,59 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
if (data.isMoving) {
|
if (data.isMoving) {
|
||||||
// 기존 컴포넌트 재배치
|
// 기존 컴포넌트 재배치
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
const x = Math.floor((e.clientX - rect.left) / 80) * 80;
|
// 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준)
|
||||||
const y = Math.floor((e.clientY - rect.top) / 60) * 60;
|
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;
|
||||||
|
|
||||||
const newLayout = {
|
if (data.isMultiDrag && data.selectedComponentIds) {
|
||||||
...layout,
|
// 다중 드래그 처리
|
||||||
components: layout.components.map((comp) => (comp.id === data.id ? { ...comp, position: { x, y } } : comp)),
|
// 그랩한 컴포넌트의 시작 위치 기준 델타 계산 (그랩 오프셋 반영)
|
||||||
};
|
const dropX = mouseX - dragState.grabOffset.x;
|
||||||
setLayout(newLayout);
|
const dropY = mouseY - dragState.grabOffset.y;
|
||||||
saveToHistory(newLayout);
|
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 {
|
} else {
|
||||||
// 새 컴포넌트 추가
|
// 새 컴포넌트 추가
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
const x = Math.floor((e.clientX - rect.left) / 80) * 80;
|
// 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준)
|
||||||
const y = Math.floor((e.clientY - rect.top) / 60) * 60;
|
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 = {
|
const newComponent: ComponentData = {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -701,11 +892,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
setDragState({
|
setDragState({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
draggedComponent: null,
|
draggedComponent: null,
|
||||||
|
draggedComponents: [],
|
||||||
originalPosition: { x: 0, y: 0 },
|
originalPosition: { x: 0, y: 0 },
|
||||||
currentPosition: { 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({
|
setDragState({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
draggedComponent: null,
|
draggedComponent: null,
|
||||||
|
draggedComponents: [],
|
||||||
originalPosition: { x: 0, y: 0 },
|
originalPosition: { x: 0, y: 0 },
|
||||||
currentPosition: { x: 0, y: 0 },
|
currentPosition: { x: 0, y: 0 },
|
||||||
|
isMultiDrag: false,
|
||||||
|
initialMouse: { x: 0, y: 0 },
|
||||||
|
grabOffset: { x: 0, y: 0 },
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 컴포넌트 클릭 (선택)
|
// 컴포넌트 클릭 (선택)
|
||||||
const handleComponentClick = useCallback(
|
const handleComponentClick = useCallback(
|
||||||
(component: ComponentData) => {
|
(component: ComponentData, event?: React.MouseEvent) => {
|
||||||
if (groupState.isGrouping) {
|
const isShiftPressed = event?.shiftKey || false;
|
||||||
// 그룹화 모드에서는 다중 선택
|
|
||||||
|
// 그룹 컨테이너는 다중선택 대상에서 제외
|
||||||
|
const isGroupContainer = component.type === "group";
|
||||||
|
|
||||||
|
if (groupState.isGrouping || isShiftPressed) {
|
||||||
|
// 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택
|
||||||
|
if (isGroupContainer) {
|
||||||
|
// 그룹 컨테이너 클릭은 다중선택에 포함하지 않고 무시
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isSelected = groupState.selectedComponents.includes(component.id);
|
const isSelected = groupState.selectedComponents.includes(component.id);
|
||||||
setGroupState((prev) => ({
|
setGroupState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -730,16 +945,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
? prev.selectedComponents.filter((id) => id !== component.id)
|
? prev.selectedComponents.filter((id) => id !== component.id)
|
||||||
: [...prev.selectedComponents, component.id],
|
: [...prev.selectedComponents, component.id],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 시프트 키로 선택한 경우 마지막 선택된 컴포넌트를 selectedComponent로 설정
|
||||||
|
if (isShiftPressed) {
|
||||||
|
setSelectedComponent(component);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 일반 모드에서는 단일 선택
|
// 일반 모드에서는 단일 선택
|
||||||
setSelectedComponent(component);
|
setSelectedComponent(component);
|
||||||
setGroupState((prev) => ({
|
setGroupState((prev) => ({
|
||||||
...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"}
|
variant={groupState.isGrouping ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setGroupState((prev) => ({ ...prev, isGrouping: !prev.isGrouping }))}
|
onClick={() => setGroupState((prev) => ({ ...prev, isGrouping: !prev.isGrouping }))}
|
||||||
title="그룹화 모드 토글"
|
title="그룹화 모드 토글 (일반 모드에서도 Shift+클릭으로 다중선택 가능)"
|
||||||
>
|
>
|
||||||
<Group className="mr-2 h-4 w-4" />
|
<Group className="mr-2 h-4 w-4" />
|
||||||
{groupState.isGrouping ? "그룹화 모드" : "일반 모드"}
|
{groupState.isGrouping ? "그룹화 모드" : "일반 모드"}
|
||||||
|
|
@ -807,6 +1027,75 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onGroupUngroup={handleGroupUngroup}
|
onGroupUngroup={handleGroupUngroup}
|
||||||
selectedComponents={layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))}
|
selectedComponents={layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))}
|
||||||
allComponents={layout.components}
|
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",
|
type: "container",
|
||||||
tableName: table.tableName,
|
tableName: table.tableName,
|
||||||
label: table.tableLabel,
|
label: table.tableLabel,
|
||||||
size: { width: 12, height: 80 },
|
size: { width: 200, height: 80 }, // 픽셀 단위로 변경
|
||||||
},
|
},
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
@ -896,7 +1185,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
widgetType: widgetType as WebType,
|
widgetType: widgetType as WebType,
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
size: { width: 6, height: 40 },
|
size: { width: 150, height: 40 }, // 픽셀 단위로 변경
|
||||||
},
|
},
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
|
|
@ -964,12 +1253,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
</div>
|
</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="h-full w-full overflow-auto p-6">
|
||||||
<div
|
<div
|
||||||
className="min-h-full rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4"
|
className="min-h-full rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4"
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
|
ref={canvasRef}
|
||||||
|
onMouseDown={handleMarqueeStart}
|
||||||
|
onMouseMove={handleMarqueeMove}
|
||||||
|
onMouseUp={handleMarqueeEnd}
|
||||||
>
|
>
|
||||||
{layout.components.length === 0 ? (
|
{layout.components.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
|
|
@ -990,6 +1283,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
</div>
|
</div>
|
||||||
</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
|
{layout.components
|
||||||
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
|
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
|
||||||
|
|
@ -1008,7 +1314,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
selectedComponent?.id === component.id ||
|
selectedComponent?.id === component.id ||
|
||||||
groupState.selectedComponents.includes(component.id)
|
groupState.selectedComponents.includes(component.id)
|
||||||
}
|
}
|
||||||
onClick={() => handleComponentClick(component)}
|
onClick={(e) => handleComponentClick(component, e)}
|
||||||
onDragStart={(e) => startComponentDrag(component, e)}
|
onDragStart={(e) => startComponentDrag(component, e)}
|
||||||
onDragEnd={endDrag}
|
onDragEnd={endDrag}
|
||||||
onGroupToggle={(groupId) => {
|
onGroupToggle={(groupId) => {
|
||||||
|
|
@ -1022,7 +1328,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
key={child.id}
|
key={child.id}
|
||||||
component={child}
|
component={child}
|
||||||
isSelected={groupState.selectedComponents.includes(child.id)}
|
isSelected={groupState.selectedComponents.includes(child.id)}
|
||||||
onClick={() => handleComponentClick(child)}
|
onClick={(e) => handleComponentClick(child, e)}
|
||||||
onDragStart={(e) => startComponentDrag(child, e)}
|
onDragStart={(e) => startComponentDrag(child, e)}
|
||||||
onDragEnd={endDrag}
|
onDragEnd={endDrag}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,15 @@ export function createGroupComponent(
|
||||||
boundingBox?: { width: number; height: number },
|
boundingBox?: { width: number; height: number },
|
||||||
style?: any,
|
style?: any,
|
||||||
): GroupComponent {
|
): GroupComponent {
|
||||||
// 격자 기반 크기 계산
|
// 픽셀 기반 크기 계산 (격자 제거)
|
||||||
const gridWidth = Math.max(6, Math.ceil(boundingBox?.width / 80) + 2); // 최소 6 그리드, 여백 2
|
const groupWidth = Math.max(200, (boundingBox?.width || 200) + 40); // 최소 200px, 여백 40px
|
||||||
const gridHeight = Math.max(100, (boundingBox?.height || 200) + 40); // 최소 100px, 여백 40px
|
const groupHeight = Math.max(100, (boundingBox?.height || 200) + 40); // 최소 100px, 여백 40px
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "group",
|
type: "group",
|
||||||
position,
|
position,
|
||||||
size: { width: gridWidth, height: gridHeight },
|
size: { width: groupWidth, height: groupHeight },
|
||||||
label: title, // title 대신 label 사용
|
label: title, // title 대신 label 사용
|
||||||
backgroundColor: "#f8f9fa",
|
backgroundColor: "#f8f9fa",
|
||||||
border: "1px solid #dee2e6",
|
border: "1px solid #dee2e6",
|
||||||
|
|
@ -39,7 +39,7 @@ export function createGroupComponent(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택된 컴포넌트들의 경계 박스 계산 (격자 기반)
|
// 선택된 컴포넌트들의 경계 박스 계산 (픽셀 기반)
|
||||||
export function calculateBoundingBox(components: ComponentData[]): {
|
export function calculateBoundingBox(components: ComponentData[]): {
|
||||||
minX: number;
|
minX: number;
|
||||||
minY: number;
|
minY: number;
|
||||||
|
|
@ -54,7 +54,7 @@ export function calculateBoundingBox(components: ComponentData[]): {
|
||||||
|
|
||||||
const minX = Math.min(...components.map((c) => c.position.x));
|
const minX = Math.min(...components.map((c) => c.position.x));
|
||||||
const minY = Math.min(...components.map((c) => c.position.y));
|
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));
|
const maxY = Math.max(...components.map((c) => c.position.y + c.size.height));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue