테두리 설정
This commit is contained in:
parent
984dd70505
commit
ff2b3c37c6
|
|
@ -30,6 +30,17 @@
|
|||
- **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인 가능
|
||||
- **메뉴 연동**: 각 회사의 메뉴에 화면 할당 및 관리
|
||||
|
||||
### 🆕 최근 업데이트 (요약)
|
||||
|
||||
- **픽셀 기반 자유 이동**: 격자 스냅을 제거하고 커서를 따라 정확히 이동하도록 구현. 그리드 라인은 시각적 가이드만 유지
|
||||
- **멀티 선택 강화**: Shift+클릭 + 드래그 박스(마키)로 다중선택 가능. 그룹 컨테이너는 선택에서 자동 제외
|
||||
- **다중 드래그 이동**: 다중선택 항목을 함께 이동(상대 위치 유지). 스크롤/그랩 오프셋 반영으로 튐 현상 제거
|
||||
- **그룹 UI 간소화**: 그룹 헤더/테두리 박스 제거(투명 컨테이너). 그룹 내부에만 집중
|
||||
- **그룹 내 정렬/분배 툴**: 좌/가로중앙/우, 상/세로중앙/하 정렬 + 가로/세로 균등 분배 추가(아이콘 UI)
|
||||
- **왼쪽 목록 UX**: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화
|
||||
- **Undo/Redo**: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y)
|
||||
- **위젯 타입 렌더링 보강**: code/entity/file 포함 실제 위젯 형태로 표시
|
||||
|
||||
### 🎯 **현재 테이블 구조와 100% 호환**
|
||||
|
||||
**기존 테이블 타입관리 시스템과 완벽 연계:**
|
||||
|
|
@ -571,6 +582,8 @@ size: { width: number; height: number };
|
|||
|
||||
### 2. 컴포넌트 배치 로직
|
||||
|
||||
현재 배치 로직은 **픽셀 기반 자유 위치**로 동작합니다. 마우스 그랩 오프셋과 스크롤 오프셋을 반영하여 커서를 정확히 추적합니다. 아래 그리드 기반 예시는 참고용이며, 실제 런타임에서는 스냅을 적용하지 않습니다.
|
||||
|
||||
```typescript
|
||||
// 그리드 기반 배치
|
||||
function calculateGridPosition(
|
||||
|
|
@ -1171,6 +1184,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}, [undo, redo]);
|
||||
```
|
||||
|
||||
#### 선택/이동 UX (현행)
|
||||
|
||||
- Shift+클릭으로 다중선택 가능
|
||||
- 캔버스 빈 영역 드래그로 **마키 선택** 가능(Shift 누르면 기존 선택에 추가)
|
||||
- 다중선택 상태에서 드래그 시 전체가 함께 이동(상대 좌표 유지)
|
||||
- 그룹 컨테이너는 선택/정렬 대상에서 자동 제외
|
||||
|
||||
// 컴포넌트 추가
|
||||
const addComponent = (component: ComponentData) => {
|
||||
setLayout((prev) => ({
|
||||
|
|
@ -2265,26 +2285,31 @@ export class TableTypeIntegrationService {
|
|||
|
||||
- **직관적 인터페이스**: 드래그앤드롭 기반 화면 설계
|
||||
- **실시간 피드백**: 컴포넌트 배치 즉시 미리보기 표시
|
||||
- **키보드 지원**: Ctrl+Z/Ctrl+Y 단축키로 빠른 작업
|
||||
- **다중선택**: Shift+클릭 및 마키 선택 지원, 다중 드래그 이동
|
||||
- **정렬/분배**: 그룹 내 좌/중앙/우·상/중앙/하 정렬 및 균등 분배
|
||||
- **키보드 지원**: Ctrl/Cmd+Z, Ctrl/Cmd+Y 단축키
|
||||
- **반응형 UI**: 전체 화면 활용한 효율적인 레이아웃
|
||||
|
||||
## 🚀 다음 단계 계획
|
||||
|
||||
### 1. 컴포넌트 그룹화 기능
|
||||
|
||||
- [ ] 여러 위젯을 컨테이너로 그룹화
|
||||
- [ ] 부모-자식 관계 설정
|
||||
- [ ] 그룹 단위 이동/삭제 기능
|
||||
- [x] 여러 위젯을 컨테이너로 그룹화
|
||||
- [x] 부모-자식 관계 설정(parentId)
|
||||
- [x] 그룹 단위 이동
|
||||
- [x] 그룹 UI 단순화(헤더/박스 제거)
|
||||
- [x] 그룹 내 정렬/균등 분배 도구(아이콘 UI)
|
||||
- [ ] 그룹 단위 삭제/복사/붙여넣기
|
||||
|
||||
### 2. 레이아웃 저장/로드
|
||||
|
||||
- [ ] 설계한 화면을 데이터베이스에 저장
|
||||
- [ ] 설계한 화면을 데이터베이스에 저장 (프론트 통합 진행 필요)
|
||||
- [ ] 저장된 화면 불러오기 기능
|
||||
- [ ] 버전 관리 시스템
|
||||
|
||||
### 3. 데이터 바인딩
|
||||
|
||||
- [ ] 실제 데이터베이스와 연결
|
||||
- [ ] 실제 데이터베이스와 연결 (메타데이터 연동은 완료)
|
||||
- [ ] 폼 제출 및 데이터 저장
|
||||
- [ ] 유효성 검증 시스템
|
||||
|
||||
|
|
|
|||
|
|
@ -36,16 +36,22 @@ interface RealtimePreviewProps {
|
|||
|
||||
// 웹 타입에 따른 위젯 렌더링
|
||||
const renderWidget = (component: ComponentData) => {
|
||||
const { widgetType, label, placeholder, required, readonly, columnName } = component;
|
||||
const { widgetType, label, placeholder, required, readonly, columnName, style } = component;
|
||||
|
||||
// 디버깅: 실제 widgetType 값 확인
|
||||
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 = {
|
||||
placeholder: placeholder || `입력하세요...`,
|
||||
disabled: readonly,
|
||||
required: required,
|
||||
className: "w-full h-full",
|
||||
className: `w-full h-full ${borderClass}`,
|
||||
};
|
||||
|
||||
switch (widgetType) {
|
||||
|
|
@ -68,7 +74,9 @@ const renderWidget = (component: ComponentData) => {
|
|||
<select
|
||||
disabled={readonly}
|
||||
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="option1">옵션 1</option>
|
||||
|
|
@ -130,7 +138,12 @@ const renderWidget = (component: ComponentData) => {
|
|||
|
||||
case "code":
|
||||
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":
|
||||
|
|
@ -138,7 +151,9 @@ const renderWidget = (component: ComponentData) => {
|
|||
<select
|
||||
disabled={readonly}
|
||||
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="user">사용자</option>
|
||||
|
|
@ -153,7 +168,9 @@ const renderWidget = (component: ComponentData) => {
|
|||
type="file"
|
||||
disabled={readonly}
|
||||
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,17 +225,34 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|||
}) => {
|
||||
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 (
|
||||
<div
|
||||
className={`absolute cursor-move transition-all ${
|
||||
isSelected ? "ring-opacity-50 ring-2 ring-blue-500" : "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300"
|
||||
}`}
|
||||
className={`absolute cursor-move transition-all ${defaultRingClass}`}
|
||||
style={{
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: `${size.width}px`, // 격자 기반 계산 제거
|
||||
height: `${size.height}px`,
|
||||
...style,
|
||||
...selectionStyle,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -171,6 +171,46 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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) => {
|
||||
|
|
@ -609,8 +649,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
setLayout(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],
|
||||
);
|
||||
|
||||
// 그룹 생성 함수
|
||||
|
|
@ -1365,10 +1410,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
id="positionX"
|
||||
type="number"
|
||||
min="0"
|
||||
value={selectedComponent.position.x}
|
||||
onChange={(e) =>
|
||||
updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value))
|
||||
}
|
||||
value={liveSelectedPosition.x}
|
||||
onChange={(e) => {
|
||||
const val = (e.target as HTMLInputElement).valueAsNumber;
|
||||
if (Number.isFinite(val)) {
|
||||
updateComponentProperty(selectedComponent.id, "position.x", Math.round(val));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1377,10 +1425,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
id="positionY"
|
||||
type="number"
|
||||
min="0"
|
||||
value={selectedComponent.position.y}
|
||||
onChange={(e) =>
|
||||
updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value))
|
||||
}
|
||||
value={liveSelectedPosition.y}
|
||||
onChange={(e) => {
|
||||
const val = (e.target as HTMLInputElement).valueAsNumber;
|
||||
if (Number.isFinite(val)) {
|
||||
updateComponentProperty(selectedComponent.id, "position.y", Math.round(val));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1392,12 +1443,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
min="1"
|
||||
max="12"
|
||||
min="20"
|
||||
value={selectedComponent.size.width}
|
||||
onChange={(e) =>
|
||||
updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = (e.target as HTMLInputElement).valueAsNumber;
|
||||
if (Number.isFinite(val)) {
|
||||
updateComponentProperty(
|
||||
selectedComponent.id,
|
||||
"size.width",
|
||||
Math.max(20, Math.round(val)),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1407,9 +1464,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
type="number"
|
||||
min="20"
|
||||
value={selectedComponent.size.height}
|
||||
onChange={(e) =>
|
||||
updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = (e.target as HTMLInputElement).valueAsNumber;
|
||||
if (Number.isFinite(val)) {
|
||||
updateComponentProperty(
|
||||
selectedComponent.id,
|
||||
"size.height",
|
||||
Math.max(20, Math.round(val)),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -88,29 +88,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
|
||||
{/* 레이아웃 탭 */}
|
||||
<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="space-y-2">
|
||||
<Label htmlFor="display">표시 방식</Label>
|
||||
|
|
|
|||
Loading…
Reference in New Issue