Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into sidebar/i18n
This commit is contained in:
commit
fba3ff9a48
|
|
@ -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. 데이터 바인딩
|
||||||
|
|
||||||
- [ ] 실제 데이터베이스와 연결
|
- [ ] 실제 데이터베이스와 연결 (메타데이터 연동은 완료)
|
||||||
- [ ] 폼 제출 및 데이터 저장
|
- [ ] 폼 제출 및 데이터 저장
|
||||||
- [ ] 유효성 검증 시스템
|
- [ ] 유효성 검증 시스템
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; // 그룹 접기/펼치기
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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