Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into sidebar/i18n

This commit is contained in:
hyeonsu 2025-09-01 17:16:53 +09:00
commit fba3ff9a48
6 changed files with 580 additions and 133 deletions

View File

@ -30,6 +30,17 @@
- **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인 가능 - **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인 가능
- **메뉴 연동**: 각 회사의 메뉴에 화면 할당 및 관리 - **메뉴 연동**: 각 회사의 메뉴에 화면 할당 및 관리
### 🆕 최근 업데이트 (요약)
- **픽셀 기반 자유 이동**: 격자 스냅을 제거하고 커서를 따라 정확히 이동하도록 구현. 그리드 라인은 시각적 가이드만 유지
- **멀티 선택 강화**: Shift+클릭 + 드래그 박스(마키)로 다중선택 가능. 그룹 컨테이너는 선택에서 자동 제외
- **다중 드래그 이동**: 다중선택 항목을 함께 이동(상대 위치 유지). 스크롤/그랩 오프셋 반영으로 튐 현상 제거
- **그룹 UI 간소화**: 그룹 헤더/테두리 박스 제거(투명 컨테이너). 그룹 내부에만 집중
- **그룹 내 정렬/분배 툴**: 좌/가로중앙/우, 상/세로중앙/하 정렬 + 가로/세로 균등 분배 추가(아이콘 UI)
- **왼쪽 목록 UX**: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화
- **Undo/Redo**: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y)
- **위젯 타입 렌더링 보강**: code/entity/file 포함 실제 위젯 형태로 표시
### 🎯 **현재 테이블 구조와 100% 호환** ### 🎯 **현재 테이블 구조와 100% 호환**
**기존 테이블 타입관리 시스템과 완벽 연계:** **기존 테이블 타입관리 시스템과 완벽 연계:**
@ -571,6 +582,8 @@ size: { width: number; height: number };
### 2. 컴포넌트 배치 로직 ### 2. 컴포넌트 배치 로직
현재 배치 로직은 **픽셀 기반 자유 위치**로 동작합니다. 마우스 그랩 오프셋과 스크롤 오프셋을 반영하여 커서를 정확히 추적합니다. 아래 그리드 기반 예시는 참고용이며, 실제 런타임에서는 스냅을 적용하지 않습니다.
```typescript ```typescript
// 그리드 기반 배치 // 그리드 기반 배치
function calculateGridPosition( function calculateGridPosition(
@ -1171,6 +1184,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}, [undo, redo]); }, [undo, redo]);
``` ```
#### 선택/이동 UX (현행)
- Shift+클릭으로 다중선택 가능
- 캔버스 빈 영역 드래그로 **마키 선택** 가능(Shift 누르면 기존 선택에 추가)
- 다중선택 상태에서 드래그 시 전체가 함께 이동(상대 좌표 유지)
- 그룹 컨테이너는 선택/정렬 대상에서 자동 제외
// 컴포넌트 추가 // 컴포넌트 추가
const addComponent = (component: ComponentData) => { const addComponent = (component: ComponentData) => {
setLayout((prev) => ({ setLayout((prev) => ({
@ -2265,26 +2285,31 @@ export class TableTypeIntegrationService {
- **직관적 인터페이스**: 드래그앤드롭 기반 화면 설계 - **직관적 인터페이스**: 드래그앤드롭 기반 화면 설계
- **실시간 피드백**: 컴포넌트 배치 즉시 미리보기 표시 - **실시간 피드백**: 컴포넌트 배치 즉시 미리보기 표시
- **키보드 지원**: Ctrl+Z/Ctrl+Y 단축키로 빠른 작업 - **다중선택**: Shift+클릭 및 마키 선택 지원, 다중 드래그 이동
- **정렬/분배**: 그룹 내 좌/중앙/우·상/중앙/하 정렬 및 균등 분배
- **키보드 지원**: Ctrl/Cmd+Z, Ctrl/Cmd+Y 단축키
- **반응형 UI**: 전체 화면 활용한 효율적인 레이아웃 - **반응형 UI**: 전체 화면 활용한 효율적인 레이아웃
## 🚀 다음 단계 계획 ## 🚀 다음 단계 계획
### 1. 컴포넌트 그룹화 기능 ### 1. 컴포넌트 그룹화 기능
- [ ] 여러 위젯을 컨테이너로 그룹화 - [x] 여러 위젯을 컨테이너로 그룹화
- [ ] 부모-자식 관계 설정 - [x] 부모-자식 관계 설정(parentId)
- [ ] 그룹 단위 이동/삭제 기능 - [x] 그룹 단위 이동
- [x] 그룹 UI 단순화(헤더/박스 제거)
- [x] 그룹 내 정렬/균등 분배 도구(아이콘 UI)
- [ ] 그룹 단위 삭제/복사/붙여넣기
### 2. 레이아웃 저장/로드 ### 2. 레이아웃 저장/로드
- [ ] 설계한 화면을 데이터베이스에 저장 - [ ] 설계한 화면을 데이터베이스에 저장 (프론트 통합 진행 필요)
- [ ] 저장된 화면 불러오기 기능 - [ ] 저장된 화면 불러오기 기능
- [ ] 버전 관리 시스템 - [ ] 버전 관리 시스템
### 3. 데이터 바인딩 ### 3. 데이터 바인딩
- [ ] 실제 데이터베이스와 연결 - [ ] 실제 데이터베이스와 연결 (메타데이터 연동은 완료)
- [ ] 폼 제출 및 데이터 저장 - [ ] 폼 제출 및 데이터 저장
- [ ] 유효성 검증 시스템 - [ ] 유효성 검증 시스템

View File

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

View File

@ -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; // 그룹 접기/펼치기
@ -36,16 +36,22 @@ interface RealtimePreviewProps {
// 웹 타입에 따른 위젯 렌더링 // 웹 타입에 따른 위젯 렌더링
const renderWidget = (component: ComponentData) => { const renderWidget = (component: ComponentData) => {
const { widgetType, label, placeholder, required, readonly, columnName } = component; const { widgetType, label, placeholder, required, readonly, columnName, style } = component;
// 디버깅: 실제 widgetType 값 확인 // 디버깅: 실제 widgetType 값 확인
console.log("RealtimePreview - widgetType:", widgetType, "columnName:", columnName); console.log("RealtimePreview - widgetType:", widgetType, "columnName:", columnName);
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
const borderClass = hasCustomBorder ? "!border-0" : "";
const commonProps = { const commonProps = {
placeholder: placeholder || `입력하세요...`, placeholder: placeholder || `입력하세요...`,
disabled: readonly, disabled: readonly,
required: required, required: required,
className: "w-full h-full", className: `w-full h-full ${borderClass}`,
}; };
switch (widgetType) { switch (widgetType) {
@ -68,7 +74,9 @@ const renderWidget = (component: ComponentData) => {
<select <select
disabled={readonly} disabled={readonly}
required={required} required={required}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100" className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
hasCustomBorder ? "!border-0" : "border border-gray-300"
}`}
> >
<option value="">{placeholder || "선택하세요..."}</option> <option value="">{placeholder || "선택하세요..."}</option>
<option value="option1"> 1</option> <option value="option1"> 1</option>
@ -130,7 +138,12 @@ const renderWidget = (component: ComponentData) => {
case "code": case "code":
return ( return (
<Textarea {...commonProps} rows={4} className="w-full font-mono text-sm" placeholder="코드를 입력하세요..." /> <Textarea
{...commonProps}
rows={4}
className={`w-full font-mono text-sm ${borderClass}`}
placeholder="코드를 입력하세요..."
/>
); );
case "entity": case "entity":
@ -138,7 +151,9 @@ const renderWidget = (component: ComponentData) => {
<select <select
disabled={readonly} disabled={readonly}
required={required} required={required}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100" className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
hasCustomBorder ? "!border-0" : "border border-gray-300"
}`}
> >
<option value=""> ...</option> <option value=""> ...</option>
<option value="user"></option> <option value="user"></option>
@ -153,7 +168,9 @@ const renderWidget = (component: ComponentData) => {
type="file" type="file"
disabled={readonly} disabled={readonly}
required={required} required={required}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100" className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
hasCustomBorder ? "!border-0" : "border border-gray-300"
}`}
/> />
); );
@ -208,19 +225,39 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
}) => { }) => {
const { type, label, tableName, columnName, widgetType, size, style } = component; const { type, label, tableName, columnName, widgetType, size, style } = component;
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 선택 테두리는 사용자 테두리가 없을 때만 적용
const defaultRingClass = hasCustomBorder
? ""
: isSelected
? "ring-opacity-50 ring-2 ring-blue-500"
: "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300";
// 사용자 테두리가 있을 때 선택 상태 표시를 위한 스타일
const selectionStyle =
hasCustomBorder && isSelected
? {
boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)", // 외부 그림자로 선택 표시
...style,
}
: style;
return ( return (
<div <div
className={`absolute cursor-move transition-all ${ className={`absolute cursor-move transition-all ${defaultRingClass}`}
isSelected ? "ring-opacity-50 ring-2 ring-blue-500" : "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300"
}`}
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, ...selectionStyle,
}}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}} }}
onClick={onClick}
draggable draggable
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
@ -242,34 +279,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>
)} )}

View File

@ -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,132 @@ 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 selectedFromLayout = useMemo(() => {
if (!selectedComponent) return null;
return layout.components.find((c) => c.id === selectedComponent.id) || null;
}, [selectedComponent, layout.components]);
// 드래그 중에는 라이브 좌표를 계산하여 속성 패널에 표시
const liveSelectedPosition = useMemo(() => {
if (!selectedFromLayout) return { x: 0, y: 0 };
let x = selectedFromLayout.position.x;
let y = selectedFromLayout.position.y;
if (dragState.isDragging) {
const isSelectedInMulti = groupState.selectedComponents.includes(selectedFromLayout.id);
if (dragState.isMultiDrag && isSelectedInMulti) {
const deltaX = dragState.currentPosition.x - dragState.initialMouse.x;
const deltaY = dragState.currentPosition.y - dragState.initialMouse.y;
x = selectedFromLayout.position.x + deltaX;
y = selectedFromLayout.position.y + deltaY;
} else if (dragState.draggedComponent?.id === selectedFromLayout.id) {
x = dragState.currentPosition.x - dragState.grabOffset.x;
y = dragState.currentPosition.y - dragState.grabOffset.y;
}
}
return { x: Math.round(x), y: Math.round(y) };
}, [
selectedFromLayout,
dragState.isDragging,
dragState.isMultiDrag,
dragState.currentPosition.x,
dragState.currentPosition.y,
dragState.initialMouse.x,
dragState.initialMouse.y,
dragState.grabOffset.x,
dragState.grabOffset.y,
groupState.selectedComponents,
]);
// 컴포넌트의 절대 좌표 계산 (그룹 자식은 부모 오프셋을 누적)
const getAbsolutePosition = useCallback(
(comp: ComponentData) => {
let x = comp.position.x;
let y = comp.position.y;
let cur: ComponentData | undefined = comp;
while (cur.parentId) {
const parent = layout.components.find((c) => c.id === cur!.parentId);
if (!parent) break;
x += parent.position.x;
y += parent.position.y;
cur = parent;
}
return { x, y };
},
[layout.components],
);
// 마키 선택 시작 (캔버스 빈 영역 마우스다운)
const handleMarqueeStart = useCallback(
(e: React.MouseEvent) => {
if (dragState.isDragging) return; // 드래그 중이면 무시
const rect = canvasRef.current?.getBoundingClientRect();
const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
const x = rect ? e.clientX - rect.left + scrollLeft : 0;
const y = rect ? e.clientY - rect.top + scrollTop : 0;
setSelectionState({ isSelecting: true, start: { x, y }, current: { x, y } });
// 기존 선택 초기화 (Shift 미사용 시)
if (!e.shiftKey) {
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
},
[dragState.isDragging],
);
// 마키 이동
const handleMarqueeMove = useCallback(
(e: React.MouseEvent) => {
if (!selectionState.isSelecting) return;
const rect = canvasRef.current?.getBoundingClientRect();
const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
const x = rect ? e.clientX - rect.left + scrollLeft : 0;
const y = rect ? e.clientY - rect.top + scrollTop : 0;
setSelectionState((prev) => ({ ...prev, current: { x, y } }));
},
[selectionState.isSelecting],
);
// 마키 종료 -> 영역 내 컴포넌트 선택
const handleMarqueeEnd = useCallback(() => {
if (!selectionState.isSelecting) return;
const minX = Math.min(selectionState.start.x, selectionState.current.x);
const minY = Math.min(selectionState.start.y, selectionState.current.y);
const maxX = Math.max(selectionState.start.x, selectionState.current.x);
const maxY = Math.max(selectionState.start.y, selectionState.current.y);
const selectedIds = layout.components
// 그룹 컨테이너는 제외
.filter((c) => c.type !== "group")
.filter((c) => {
const abs = getAbsolutePosition(c);
const left = abs.x;
const top = abs.y;
const right = abs.x + c.size.width;
const bottom = abs.y + c.size.height;
// 영역과 교차 여부 판단 (일부라도 겹치면 선택)
return right >= minX && left <= maxX && bottom >= minY && top <= maxY;
})
.map((c) => c.id);
setGroupState((prev) => ({
...prev,
selectedComponents: Array.from(new Set([...prev.selectedComponents, ...selectedIds])),
}));
setSelectionState({ isSelecting: false, start: { x: 0, y: 0 }, current: { x: 0, y: 0 } });
}, [selectionState, layout.components, getAbsolutePosition]);
// 테이블 데이터 로드 (실제로는 API에서 가져와야 함) // 테이블 데이터 로드 (실제로는 API에서 가져와야 함)
useEffect(() => { useEffect(() => {
const fetchTables = async () => { const fetchTables = async () => {
@ -519,8 +649,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
setLayout(newLayout); setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
// 선택된 컴포넌트인 경우 즉시 상태도 동기화하여 입력 즉시 반영되도록 처리
if (selectedComponent && selectedComponent.id === componentId) {
const updated = newLayout.components.find((c) => c.id === componentId) || null;
if (updated) setSelectedComponent(updated);
}
}, },
[layout, saveToHistory], [layout, saveToHistory, selectedComponent],
); );
// 그룹 생성 함수 // 그룹 생성 함수
@ -614,37 +749,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 +863,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 +937,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 +960,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 +990,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 +1038,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 +1072,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 +1184,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 +1230,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 +1298,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 +1328,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 +1359,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 +1373,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}
/> />
@ -1059,10 +1410,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
id="positionX" id="positionX"
type="number" type="number"
min="0" min="0"
value={selectedComponent.position.x} value={liveSelectedPosition.x}
onChange={(e) => onChange={(e) => {
updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value)) const val = (e.target as HTMLInputElement).valueAsNumber;
} if (Number.isFinite(val)) {
updateComponentProperty(selectedComponent.id, "position.x", Math.round(val));
}
}}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -1071,10 +1425,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
id="positionY" id="positionY"
type="number" type="number"
min="0" min="0"
value={selectedComponent.position.y} value={liveSelectedPosition.y}
onChange={(e) => onChange={(e) => {
updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value)) const val = (e.target as HTMLInputElement).valueAsNumber;
} if (Number.isFinite(val)) {
updateComponentProperty(selectedComponent.id, "position.y", Math.round(val));
}
}}
/> />
</div> </div>
</div> </div>
@ -1086,12 +1443,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<Input <Input
id="width" id="width"
type="number" type="number"
min="1" min="20"
max="12"
value={selectedComponent.size.width} value={selectedComponent.size.width}
onChange={(e) => onChange={(e) => {
updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value)) const val = (e.target as HTMLInputElement).valueAsNumber;
} if (Number.isFinite(val)) {
updateComponentProperty(
selectedComponent.id,
"size.width",
Math.max(20, Math.round(val)),
);
}
}}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -1101,9 +1464,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
type="number" type="number"
min="20" min="20"
value={selectedComponent.size.height} value={selectedComponent.size.height}
onChange={(e) => onChange={(e) => {
updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value)) const val = (e.target as HTMLInputElement).valueAsNumber;
} if (Number.isFinite(val)) {
updateComponentProperty(
selectedComponent.id,
"size.height",
Math.max(20, Math.round(val)),
);
}
}}
/> />
</div> </div>
</div> </div>

View File

@ -88,29 +88,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
{/* 레이아웃 탭 */} {/* 레이아웃 탭 */}
<TabsContent value="layout" className="space-y-4"> <TabsContent value="layout" className="space-y-4">
<div className="grid grid-cols-2 gap-4"> {/* 너비/높이는 위젯 속성에서만 관리하도록 제거 */}
<div className="space-y-2">
<Label htmlFor="width"></Label>
<Input
id="width"
type="text"
placeholder="100px, 50%, auto"
value={localStyle.width || ""}
onChange={(e) => handleStyleChange("width", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="height"></Label>
<Input
id="height"
type="text"
placeholder="100px, 50%, auto"
value={localStyle.height || ""}
onChange={(e) => handleStyleChange("height", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="display"> </Label> <Label htmlFor="display"> </Label>

View File

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