다중선택 및 정렬기능 구현

This commit is contained in:
kjs 2025-09-01 16:40:24 +09:00
parent e18c78f40d
commit 984dd70505
4 changed files with 423 additions and 77 deletions

View File

@ -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>

View File

@ -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>
)}

View File

@ -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}
/>

View File

@ -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 {