revert: e27845a 커밋의 변경사항 되돌림 - 화면 레이아웃 문제 수정

This commit is contained in:
kjs 2025-11-10 14:21:29 +09:00
parent 02644f38ee
commit 15f21a1142
10 changed files with 1095 additions and 349 deletions

View File

@ -1,22 +1,5 @@
# Cursor Rules for ERP-node Project
## 🔥 필수 확인 규칙 (작업 시작 전 & 완료 후)
**AI 에이전트는 모든 작업을 시작하기 전과 완료한 후에 반드시 다음 파일을 확인해야 합니다:**
- [AI-개발자 협업 작업 수칙](.cursor/rules/ai-developer-collaboration-rules.mdc)
**핵심 3원칙:**
1. **확인 우선** 🔍 - 추측하지 말고, 항상 확인하고 작업
2. **한 번에 하나** 🎯 - 여러 문제를 동시에 해결하려 하지 말기
3. **철저한 마무리** ✨ - 로그 제거, 테스트, 명확한 설명
**절대 금지:**
- ❌ 확인 없이 "완료했습니다" 말하기
- ❌ 데이터베이스 컬럼명 추측하기 (반드시 MCP로 확인)
- ❌ 디버깅 로그를 남겨둔 채 작업 종료
---
## 🚨 최우선 보안 규칙: 멀티테넌시
**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:**

1
.gitignore vendored
View File

@ -287,4 +287,3 @@ uploads/
*.hwpx
claude.md
.cursor/rules/ai-developer-collaboration-rules.mdc

View File

@ -57,7 +57,7 @@ interface RealtimePreviewProps {
isSelected?: boolean;
isDesignMode?: boolean;
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.MouseEvent | React.DragEvent) => void; // MouseEvent도 허용
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
@ -247,13 +247,6 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}) => {
const { user } = useAuth();
const { type, id, position, size, style = {} } = component;
// 🔍 [디버깅] 렌더링 시 크기 로그
console.log("🎨 [RealtimePreview] 렌더링", {
componentId: id,
size,
position,
});
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
const [actualHeight, setActualHeight] = useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
@ -465,17 +458,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onClick?.(e);
};
const handleMouseDown = (e: React.MouseEvent) => {
// 디자인 모드에서만 드래그 시작 (캔버스 내 이동용)
if (isDesignMode && onDragStart) {
e.stopPropagation();
// MouseEvent를 그대로 전달
onDragStart(e);
}
};
const handleDragStart = (e: React.DragEvent) => {
// HTML5 Drag API (팔레트에서 캔버스로 드래그용)
e.stopPropagation();
onDragStart?.(e);
};
@ -490,9 +473,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
className="absolute cursor-pointer"
style={{ ...componentStyle, ...selectionStyle }}
onClick={handleClick}
onMouseDown={isDesignMode ? handleMouseDown : undefined}
draggable={!isDesignMode} // 디자인 모드가 아닐 때만 draggable (팔레트용)
onDragStart={!isDesignMode ? handleDragStart : undefined}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* 컴포넌트 타입별 렌더링 */}

View File

@ -264,9 +264,6 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
height: getHeight(),
zIndex: component.type === "layout" ? 1 : position.z || 2,
...componentStyle,
// 🔥 중요: componentStyle.width를 덮어쓰기 위해 다시 설정
width: getWidth(), // size.width 기반 픽셀 값으로 강제
height: getHeight(), // size.height 기반 픽셀 값으로 강제
right: undefined,
};

View File

@ -29,9 +29,13 @@ import {
snapToGrid,
snapSizeToGrid,
generateGridLines,
updateSizeFromGridColumns,
adjustGridColumnsFromSize,
alignGroupChildrenToGrid,
calculateOptimalGroupSize,
normalizeGroupChildPositions,
calculateWidthFromColumns,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
@ -103,8 +107,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: {
snapToGrid: true, // 격자 스냅 ON
showGrid: false, // 격자 표시 OFF
columns: 12,
gap: 16,
padding: 0,
snapToGrid: true,
showGrid: false, // 기본값 false로 변경
gridColor: "#d1d5db",
gridOpacity: 0.5,
},
@ -533,31 +540,107 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
gridInfo &&
newComp.type !== "group"
) {
// 🔥 10px 고정 격자로 스냅
const currentGridInfo = calculateGridInfo(
screenResolution.width,
screenResolution.height,
prevLayout.gridSettings,
// 현재 해상도에 맞는 격자 정보로 스냅 적용
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
const snappedSize = snapSizeToGrid(
newComp.size,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, prevLayout.gridSettings);
newComp.size = snappedSize;
// 크기 변경 시 gridColumns도 자동 조정
const adjustedColumns = adjustGridColumnsFromSize(
newComp,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
if (newComp.gridColumns !== adjustedColumns) {
newComp.gridColumns = adjustedColumns;
}
}
// 🗑️ gridColumns 로직 제거: 10px 고정 격자에서는 불필요
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
// gridColumns에 맞는 정확한 너비 계산
const newWidth = calculateWidthFromColumns(
newComp.gridColumns,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
newComp.size = {
...newComp.size,
width: newWidth,
};
}
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
if (
(path === "position.x" || path === "position.y" || path === "position") &&
layout.gridSettings?.snapToGrid
) {
// 🔥 10px 고정 격자
const currentGridInfo = calculateGridInfo(
screenResolution.width,
screenResolution.height,
layout.gridSettings,
);
const snappedPosition = snapToGrid(newComp.position, currentGridInfo, layout.gridSettings);
newComp.position = snappedPosition;
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
if (newComp.parentId && currentGridInfo) {
const { columnWidth } = currentGridInfo;
const { gap } = layout.gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = newComp.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16)));
const snappedX = padding + columnIndex * (columnWidth + (gap || 16));
// Y 좌표는 10px 단위로 스냅
const effectiveY = newComp.position.y - padding;
const rowIndex = Math.round(effectiveY / 10);
const snappedY = padding + rowIndex * 10;
// 크기도 외부 격자와 동일하게 스냅
const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기
// 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거)
const snappedHeight = Math.max(10, newComp.size.height);
newComp.position = {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: newComp.position.z || 1,
};
newComp.size = {
width: snappedWidth,
height: snappedHeight,
};
} else if (newComp.type !== "group") {
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
const snappedPosition = snapToGrid(
newComp.position,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
newComp.position = snappedPosition;
}
}
return newComp;
@ -820,13 +903,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter");
const convertedComponents = convertLayoutComponents(layoutToUse.components);
// 🔥 10px 고정 격자 시스템으로 자동 마이그레이션
// 이전 columns, gap, padding 설정을 제거하고 새 시스템으로 변환
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
const layoutWithDefaultGrid = {
...layoutToUse,
components: convertedComponents, // 변환된 컴포넌트 사용
gridSettings: {
// 🗑️ 제거: columns, gap, padding (더 이상 사용하지 않음)
columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12
gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16
padding: 0, // padding은 항상 0으로 강제
snapToGrid: layoutToUse.gridSettings?.snapToGrid ?? true, // DB 값 우선
showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선
gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db",
@ -834,8 +918,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
},
};
console.log("✅ 격자 설정 로드 (10px 고정):", layoutWithDefaultGrid.gridSettings);
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
if (layoutToUse.screenResolution) {
setScreenResolution(layoutToUse.screenResolution);
@ -992,12 +1074,51 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
}, [MIN_ZOOM, MAX_ZOOM]);
// 격자 설정 업데이트 (10px 고정 격자 - 자동 스냅 제거)
// 격자 설정 업데이트 및 컴포넌트 자동 스냅
const updateGridSettings = useCallback(
(newGridSettings: GridSettings) => {
// 단순히 격자 설정만 업데이트 (컴포넌트 자동 이동 없음)
const newLayout = { ...layout, gridSettings: newGridSettings };
// 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정
if (newGridSettings.snapToGrid && screenResolution.width > 0) {
// 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준)
const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid || false,
});
const gridUtilSettings = {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid,
};
const adjustedComponents = layout.components.map((comp) => {
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
}
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정
};
});
newLayout.components = adjustedComponents;
// console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
// console.log("새로운 격자 정보:", newGridInfo);
}
setLayout(newLayout);
saveToHistory(newLayout);
},
@ -1094,13 +1215,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
// gridColumns 재계산
const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns,
};
});
console.log("🧲 격자 스냅 적용 완료");
}
const updatedLayout = {
@ -1159,10 +1285,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const snappedPosition = snapToGrid(comp.position, currentGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, currentGridInfo, gridUtilSettings);
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, currentGridInfo, gridUtilSettings);
}
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns,
};
});
@ -1321,8 +1454,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
: { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") {
// 🔥 10px 고정 격자: 기본 너비 사용
const calculatedSize = { width: 400, height: templateComp.size.height };
// 그리드 컬럼 기반 크기 계산
const gridColumns =
typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: { width: 400, height: templateComp.size.height }; // 폴백 크기
return {
id: componentId,
@ -1346,11 +1495,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 데이터 테이블 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// 🔥 10px 고정 격자: 기본 크기 사용
const calculatedSize = {
width: 800, // 데이터 테이블 기본 너비
height: templateComp.size.height,
};
// gridColumns에 맞는 크기 계산
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height, // 높이는 템플릿 값 유지
};
})()
: templateComp.size;
console.log("📊 데이터 테이블 생성 시 크기 계산:", {
gridColumns,
@ -1415,11 +1574,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 파일 첨부 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼
// 🔥 10px 고정 격자
const calculatedSize = {
width: 400,
height: templateComp.size.height,
};
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: templateComp.size;
return {
id: componentId,
@ -1457,11 +1625,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 영역 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// 🔥 10px 고정 격자
const calculatedSize = {
width: 600, // 영역 기본 너비
height: templateComp.size.height,
};
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: templateComp.size;
return {
id: componentId,
@ -1583,7 +1760,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const widgetSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? {
width: 200,
width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings),
height: templateComp.size.height,
}
: templateComp.size;
@ -1954,9 +2131,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
});
}
// 🗑️ 10px 고정 격자: gridColumns 로직 제거
// 기본 크기만 사용
componentSize = component.defaultSize;
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
if (layout.gridSettings?.snapToGrid && gridInfo) {
// gridColumns에 맞는 정확한 너비 계산
const calculatedWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
// 컴포넌트별 최소 크기 보장
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width;
componentSize = {
...component.defaultSize,
width: Math.max(calculatedWidth, minWidth),
};
}
console.log("🎨 최종 컴포넌트 크기:", {
componentId: component.id,
@ -2056,12 +2247,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
// console.log("🎯 드롭 이벤트:", { dragData });
if (!dragData) {
// console.log("❌ 드래그 데이터가 없습니다");
return;
}
try {
const parsedData = JSON.parse(dragData);
// console.log("📋 파싱된 데이터:", parsedData);
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
@ -2115,8 +2309,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
} else if (type === "column") {
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
// 🔥 10px 고정 격자 시스템: 간단한 기본 너비 사용
const defaultWidth = 200; // 기본 너비 200px
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값
const defaultWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings)
: 200;
console.log("🎯 컴포넌트 생성 시 크기 계산:", {
screenResolution: `${screenResolution.width}x${screenResolution.height}`,
gridSettings: layout.gridSettings,
currentGridInfo: currentGridInfo
? {
columnWidth: currentGridInfo.columnWidth.toFixed(2),
totalWidth: currentGridInfo.totalWidth,
}
: null,
defaultWidth: defaultWidth.toFixed(2),
snapToGrid: layout.gridSettings?.snapToGrid,
});
// 웹타입별 기본 그리드 컬럼 수 계산
const getDefaultGridColumns = (widgetType: string): number => {
@ -2155,6 +2375,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%)
console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns });
return defaultColumns;
};
@ -2167,7 +2388,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
file: 240, // 파일 업로드 (40 * 6)
};
return heightMap[widgetType] || 30; // 기본값 30px로 변경
return heightMap[widgetType] || 40; // 기본값 40
};
// 웹타입별 기본 설정 생성
@ -2326,9 +2547,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// 🔥 10px 고정 격자: 간단한 너비 계산
const componentWidth = defaultWidth;
// gridColumns에 맞는 실제 너비 계산
const componentWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(
calculatedGridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: defaultWidth;
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
widgetType: column.widgetType,
calculatedGridColumns,
componentWidth,
defaultWidth,
});
newComponent = {
id: generateComponentId(),
@ -2349,7 +2583,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
codeCategory: column.codeCategory,
}),
style: {
labelDisplay: false, // 라벨 숨김 (placeholder 사용)
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "12px",
labelColor: "#212121",
labelFontWeight: "500",
@ -2361,7 +2595,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 컬럼 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -2380,9 +2613,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// 🔥 10px 고정 격자: 간단한 너비 계산
const componentWidth = defaultWidth;
// gridColumns에 맞는 실제 너비 계산
const componentWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(
calculatedGridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: defaultWidth;
console.log("🎯 캔버스 컴포넌트 생성:", {
widgetType: column.widgetType,
calculatedGridColumns,
componentWidth,
defaultWidth,
});
// 🔍 이미지 타입 드래그앤드롭 디버깅
// if (column.widgetType === "image") {
@ -2412,7 +2658,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
codeCategory: column.codeCategory,
}),
style: {
labelDisplay: false, // 라벨 숨김 (placeholder 사용)
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
labelFontSize: "14px",
labelColor: "#000000", // 순수한 검정
labelFontWeight: "500",
@ -2424,7 +2670,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 컬럼 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -2456,6 +2701,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, currentGridInfo, gridUtilSettings);
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
type: newComponent.type,
resolution: `${screenResolution.width}x${screenResolution.height}`,
snappedPosition: newComponent.position,
snappedSize: newComponent.size,
columnWidth: currentGridInfo.columnWidth,
});
}
if (newComponent.type === "group") {
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
type: newComponent.type,
position: newComponent.position,
size: newComponent.size,
});
}
const newLayout = {
@ -2629,10 +2889,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentsToMove = [...componentsToMove, ...additionalComponents];
}
const finalGrabOffset = {
x: relativeMouseX - component.position.x,
y: relativeMouseY - component.position.y,
};
// console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
console.log("마우스 위치 (줌 보정):", {
zoomLevel,
clientX: event.clientX,
clientY: event.clientY,
rectLeft: rect.left,
rectTop: rect.top,
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
componentX: component.position.x,
componentY: component.position.y,
grabOffsetX: relativeMouseX - component.position.x,
grabOffsetY: relativeMouseY - component.position.y,
});
console.log("🚀 드래그 시작:", {
componentId: component.id,
componentType: component.type,
initialPosition: { x: component.position.x, y: component.position.y },
});
setDragState({
isDragging: true,
@ -2648,7 +2924,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
y: component.position.y,
z: (component.position as Position).z || 1,
},
grabOffset: finalGrabOffset,
grabOffset: {
x: relativeMouseX - component.position.x,
y: relativeMouseY - component.position.y,
},
justFinishedDrag: false,
});
},
@ -2676,24 +2955,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const rawX = relativeMouseX - dragState.grabOffset.x;
const rawY = relativeMouseY - dragState.grabOffset.y;
// 🔥 경계 제한 로직 제거: 컴포넌트가 화면을 벗어나도 되게 함
// 이유:
// 1. 큰 컴포넌트(884px)를 작은 영역(16px)에만 제한하는 것은 사용성 문제
// 2. 사용자가 자유롭게 배치할 수 있어야 함
// 3. 최소 위치만 0 이상으로 제한 (음수 좌표 방지)
const newPosition = {
x: Math.max(0, rawX),
y: Math.max(0, rawY),
x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)),
y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)),
z: (dragState.draggedComponent.position as Position).z || 1,
};
// 드래그 상태 업데이트
console.log("🔥 ScreenDesigner updateDragPosition (줌 보정):", {
zoomLevel,
draggedComponentId: dragState.draggedComponent.id,
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
oldPosition: dragState.currentPosition,
newPosition: newPosition,
});
setDragState((prev) => {
const newState = {
...prev,
currentPosition: { ...newPosition }, // 새로운 객체 생성
};
console.log("🔄 ScreenDesigner dragState 업데이트:", {
prevPosition: prev.currentPosition,
newPosition: newState.currentPosition,
stateChanged:
prev.currentPosition.x !== newState.currentPosition.x ||
prev.currentPosition.y !== newState.currentPosition.y,
});
return newState;
});
@ -2711,8 +3000,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
let finalPosition = dragState.currentPosition;
// 🔥 10px 고정 격자 시스템: calculateGridInfo는 columns, gap, padding을 무시함
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, layout.gridSettings);
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
@ -2723,9 +3019,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
z: dragState.currentPosition.z ?? 1,
},
currentGridInfo,
layout.gridSettings,
{
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
},
);
console.log("🎯 격자 스냅 적용됨:", {
componentType: draggedComponent?.type,
resolution: `${screenResolution.width}x${screenResolution.height}`,
originalPosition: dragState.currentPosition,
snappedPosition: finalPosition,
columnWidth: currentGridInfo.columnWidth,
});
}
// 스냅으로 인한 추가 이동 거리 계산
@ -2790,6 +3098,28 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
height: snappedHeight,
};
console.log("🎯 드래그 종료 시 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", {
componentId: comp.id,
parentId: comp.parentId,
beforeSnap: {
x: originalComponent.position.x + totalDeltaX,
y: originalComponent.position.y + totalDeltaY,
},
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
columnWidth,
fullColumnWidth,
widthInColumns,
gap: gap || 16,
padding,
},
afterSnap: newPosition,
afterSizeSnap: newSize,
});
return {
...comp,
position: newPosition as Position,
@ -2812,6 +3142,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
if (selectedComponent && dragState.draggedComponents.some((c) => c.id === selectedComponent.id)) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id);
if (updatedSelectedComponent) {
console.log("🔄 ScreenDesigner: 선택된 컴포넌트 위치 업데이트", {
componentId: selectedComponent.id,
oldPosition: selectedComponent.position,
newPosition: updatedSelectedComponent.position,
});
setSelectedComponent(updatedSelectedComponent);
}
}

View File

@ -1,79 +1,335 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Grid3X3 } from "lucide-react";
import { GridSettings } from "@/types/screen-management";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap, RefreshCw } from "lucide-react";
import { GridSettings, ScreenResolution } from "@/types/screen";
import { calculateGridInfo } from "@/lib/utils/gridUtils";
interface GridPanelProps {
gridSettings: GridSettings;
onGridSettingsChange: (settings: GridSettings) => void;
onResetGrid: () => void;
onForceGridUpdate?: () => void; // 강제 격자 재조정 추가
screenResolution?: ScreenResolution; // 해상도 정보 추가
}
/**
* (10px )
*
* :
* - ON/OFF
* - ON/OFF
*
* ( ):
* - 크기: 10px
* - 간격: 10px
*/
export function GridPanel({ gridSettings, onGridSettingsChange }: GridPanelProps) {
const updateSetting = <K extends keyof GridSettings>(key: K, value: GridSettings[K]) => {
export const GridPanel: React.FC<GridPanelProps> = ({
gridSettings,
onGridSettingsChange,
onResetGrid,
onForceGridUpdate,
screenResolution,
}) => {
const updateSetting = (key: keyof GridSettings, value: any) => {
onGridSettingsChange({
...gridSettings,
[key]: value,
});
};
// 최대 컬럼 수 계산 (최소 컬럼 너비 30px 기준)
const MIN_COLUMN_WIDTH = 30;
const maxColumns = screenResolution
? Math.floor((screenResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
: 24;
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
// 실제 격자 정보 계산
const actualGridInfo = screenResolution
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: gridSettings.columns,
gap: gridSettings.gap,
padding: gridSettings.padding,
snapToGrid: gridSettings.snapToGrid || false,
})
: null;
// 실제 표시되는 컬럼 수 계산 (항상 설정된 개수를 표시하되, 너비가 너무 작으면 경고)
const actualColumns = gridSettings.columns;
// 컬럼이 너무 작은지 확인
const isColumnsTooSmall =
screenResolution && actualGridInfo
? actualGridInfo.columnWidth < MIN_COLUMN_WIDTH
: false;
return (
<Card className="w-full">
<CardHeader className="space-y-1 pb-3">
<div className="flex items-center gap-2">
<Grid3X3 className="h-4 w-4" />
<CardTitle className="text-base"> </CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 격자 표시 */}
<div className="flex items-center justify-between">
<Label htmlFor="showGrid" className="flex items-center gap-2 text-sm font-medium">
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
</Label>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid ?? false}
onCheckedChange={(checked) => updateSetting("showGrid", checked as boolean)}
/>
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Grid3X3 className="text-muted-foreground h-4 w-4" />
<h3 className="text-sm font-semibold"> </h3>
</div>
<div className="flex items-center gap-1.5">
{onForceGridUpdate && (
<Button
size="sm"
variant="outline"
onClick={onForceGridUpdate}
className="h-7 px-2 text-xs" style={{ fontSize: "12px" }}
title="현재 해상도에 맞게 모든 컴포넌트를 격자에 재정렬합니다"
>
<RefreshCw className="mr-1 h-3 w-3" />
</Button>
)}
<Button size="sm" variant="outline" onClick={onResetGrid} className="h-7 px-2 text-xs">
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 격자 스냅 */}
<div className="flex items-center justify-between">
<Label htmlFor="snapToGrid" className="flex items-center gap-2 text-sm font-medium">
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
</Label>
<Checkbox
id="snapToGrid"
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateSetting("snapToGrid", checked as boolean)}
/>
{/* 주요 토글들 */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{gridSettings.showGrid ? (
<Eye className="text-primary h-3.5 w-3.5" />
) : (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
)}
<Label htmlFor="showGrid" className="text-xs font-medium">
</Label>
</div>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateSetting("showGrid", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="text-primary h-3.5 w-3.5" />
<Label htmlFor="snapToGrid" className="text-xs font-medium">
</Label>
</div>
<Checkbox
id="snapToGrid"
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateSetting("snapToGrid", checked)}
/>
</div>
</div>
</div>
{/* 설정 영역 */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* 격자 구조 */}
<div className="space-y-3">
<h4 className="text-xs font-semibold"> </h4>
<div className="space-y-2">
<Label htmlFor="columns" className="text-xs font-medium">
</Label>
<div className="flex items-center gap-2">
<Input
id="columns"
type="number"
min={1}
max={safeMaxColumns}
value={gridSettings.columns}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
updateSetting("columns", value);
}
}}
className="h-8 text-xs"
/>
<span className="text-muted-foreground text-xs">/ {safeMaxColumns}</span>
</div>
<Slider
id="columns-slider"
min={1}
max={safeMaxColumns}
step={1}
value={[gridSettings.columns]}
onValueChange={([value]) => updateSetting("columns", value)}
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>1</span>
<span>{safeMaxColumns}</span>
</div>
{isColumnsTooSmall && (
<p className="text-xs text-amber-600">
( {MIN_COLUMN_WIDTH}px )
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="gap" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.gap}px</span>
</Label>
<Slider
id="gap"
min={0}
max={40}
step={2}
value={[gridSettings.gap]}
onValueChange={([value]) => updateSetting("gap", value)}
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>0px</span>
<span>40px</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="padding" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.padding}px</span>
</Label>
<Slider
id="padding"
min={0}
max={60}
step={4}
value={[gridSettings.padding]}
onValueChange={([value]) => updateSetting("padding", value)}
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>0px</span>
<span>60px</span>
</div>
</div>
</div>
{/* 격자 정보 (읽기 전용) */}
<div className="mt-4 rounded-md bg-muted p-3 text-xs text-muted-foreground">
<p className="font-medium mb-1">🔧 </p>
<ul className="space-y-0.5">
<li> : <span className="font-semibold">10px </span></li>
<li> 10px </li>
<li> </li>
</ul>
<Separator />
{/* 격자 스타일 */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900"> </h4>
<div>
<Label htmlFor="gridColor" className="text-sm font-medium">
</Label>
<div className="mt-1 flex items-center space-x-2">
<Input
id="gridColor"
type="color"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
className="h-8 w-12 rounded border p-1"
/>
<Input
type="text"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
placeholder="#d1d5db"
className="flex-1"
/>
</div>
</div>
<div>
<Label htmlFor="gridOpacity" className="mb-2 block text-sm font-medium">
: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
</Label>
<Slider
id="gridOpacity"
min={0.1}
max={1}
step={0.1}
value={[gridSettings.gridOpacity || 0.5]}
onValueChange={([value]) => updateSetting("gridOpacity", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>10%</span>
<span>100%</span>
</div>
</div>
</div>
</CardContent>
</Card>
<Separator />
{/* 미리보기 */}
<div className="space-y-3">
<h4 className="font-medium text-gray-900"></h4>
<div
className="rounded-md border border-gray-200 bg-white p-4"
style={{
backgroundImage: gridSettings.showGrid
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
: "none",
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
opacity: gridSettings.gridOpacity || 0.5,
}}
>
<div className="bg-primary/20 flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300">
<span className="text-primary text-xs"> </span>
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="border-t border-gray-200 bg-gray-50 p-3">
<div className="text-muted-foreground text-xs">💡 </div>
{/* 해상도 및 격자 정보 */}
{screenResolution && actualGridInfo && (
<>
<Separator />
<div className="space-y-3">
<h4 className="font-medium text-gray-900"> </h4>
<div className="space-y-2 text-xs" style={{ fontSize: "12px" }}>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono">
{screenResolution.width} × {screenResolution.height}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className={`font-mono ${isColumnsTooSmall ? "text-destructive" : "text-gray-900"}`}>
{actualGridInfo.columnWidth.toFixed(1)}px
{isColumnsTooSmall && " (너무 작음)"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-mono">
{(screenResolution.width - gridSettings.padding * 2).toLocaleString()}px
</span>
</div>
{isColumnsTooSmall && (
<div className="rounded-md bg-yellow-50 p-2 text-xs text-yellow-800">
💡 . .
</div>
)}
</div>
</div>
</>
)}
</div>
</div>
);
}
};
export default GridPanel;

View File

@ -53,16 +53,12 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
onDragStart,
placedColumns = new Set(),
}) => {
// 숨길 기본 컬럼 목록 (id, created_date, updated_date, writer, company_code)
const hiddenColumns = new Set(['id', 'created_date', 'updated_date', 'writer', 'company_code']);
// 이미 배치된 컬럼 + 기본 컬럼을 제외한 테이블 정보 생성
// 이미 배치된 컬럼을 제외한 테이블 정보 생성
const tablesWithAvailableColumns = tables.map((table) => ({
...table,
columns: table.columns.filter((col) => {
const columnKey = `${table.tableName}.${col.columnName}`;
// 기본 컬럼 또는 이미 배치된 컬럼은 제외
return !hiddenColumns.has(col.columnName) && !placedColumns.has(columnKey);
return !placedColumns.has(columnKey);
}),
}));

View File

@ -125,23 +125,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}
}, [selectedComponent?.size?.height, selectedComponent?.id]);
// 🔥 훅은 항상 최상단에 (early return 이전)
// 크기 입력 필드용 로컬 상태
const [localSize, setLocalSize] = useState({
width: selectedComponent?.size?.width || 100,
height: selectedComponent?.size?.height || 40,
});
// 선택된 컴포넌트가 변경되면 로컬 상태 동기화
useEffect(() => {
if (selectedComponent) {
setLocalSize({
width: selectedComponent.size?.width || 100,
height: selectedComponent.size?.height || 40,
});
}
}, [selectedComponent?.id, selectedComponent?.size?.width, selectedComponent?.size?.height]);
// 격자 설정 업데이트 함수 (early return 이전에 정의)
const updateGridSetting = (key: string, value: any) => {
if (onGridSettingsChange && gridSettings) {
@ -152,10 +135,17 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}
};
// 격자 설정 렌더링 (10px 고정 격자)
// 격자 설정 렌더링 (early return 이전에 정의)
const renderGridSettings = () => {
if (!gridSettings || !onGridSettingsChange) return null;
// 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30;
const maxColumns = currentResolution
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
: 24;
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
@ -164,7 +154,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
<div className="space-y-3">
{/* 격자 표시 */}
{/* 토글들 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{gridSettings.showGrid ? (
@ -178,12 +168,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid ?? false}
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateGridSetting("showGrid", checked)}
/>
</div>
{/* 격자 스냅 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="text-primary h-3 w-3" />
@ -198,14 +187,65 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
/>
</div>
{/* 격자 정보 (읽기 전용) */}
<div className="rounded-md bg-muted p-2 text-[10px] text-muted-foreground">
<p className="font-medium mb-0.5">🔧 </p>
<ul className="space-y-0.5">
<li> : <span className="font-semibold">10px </span></li>
<li> 10px </li>
<li> </li>
</ul>
{/* 컬럼 수 */}
<div className="space-y-1">
<Label htmlFor="columns" className="text-xs font-medium">
</Label>
<div className="flex items-center gap-2">
<Input
id="columns"
type="number"
min={1}
max={safeMaxColumns}
step="1"
value={gridSettings.columns}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
updateGridSetting("columns", value);
}
}}
className="h-6 px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
placeholder={`1~${safeMaxColumns}`}
/>
</div>
<p className="text-muted-foreground text-[10px]">
{safeMaxColumns} ( {MIN_COLUMN_WIDTH}px)
</p>
</div>
{/* 간격 */}
<div className="space-y-1">
<Label htmlFor="gap" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.gap}px</span>
</Label>
<Slider
id="gap"
min={0}
max={40}
step={2}
value={[gridSettings.gap]}
onValueChange={([value]) => updateGridSetting("gap", value)}
className="w-full"
/>
</div>
{/* 여백 */}
<div className="space-y-1">
<Label htmlFor="padding" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.padding}px</span>
</Label>
<Slider
id="padding"
min={0}
max={60}
step={4}
value={[gridSettings.padding]}
onValueChange={([value]) => updateGridSetting("padding", value)}
className="w-full"
/>
</div>
</div>
</div>
@ -415,90 +455,47 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
)}
{/* Z-Index */}
<div className="space-y-1">
<Label className="text-xs">Z-Index</Label>
<Input
type="number"
step="1"
value={currentPosition.z || 1}
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
</div>
{/* 크기 (너비/높이) */}
{/* Grid Columns + Z-Index (같은 행) */}
<div className="grid grid-cols-2 gap-2">
{(selectedComponent as any).gridColumns !== undefined && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={1}
max={gridSettings?.columns || 12}
step="1"
value={(selectedComponent as any).gridColumns || 1}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
const maxColumns = gridSettings?.columns || 12;
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
handleUpdate("gridColumns", value);
// width를 퍼센트로 계산하여 업데이트
const widthPercent = (value / maxColumns) * 100;
handleUpdate("style.width", `${widthPercent}%`);
}
}}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
/{gridSettings?.columns || 12}
</span>
</div>
</div>
)}
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<Label className="text-xs">Z-Index</Label>
<Input
type="number"
step="1"
value={localSize.width}
onChange={(e) => {
// 입력 중에는 로컬 상태만 업데이트
const value = e.target.value === "" ? "" : parseInt(e.target.value);
setLocalSize((prev) => ({ ...prev, width: value as number }));
}}
onBlur={(e) => {
// 포커스 아웃 시 실제 컴포넌트 업데이트
const rawValue = e.target.value;
const parsedValue = parseInt(rawValue);
const newWidth = Math.max(10, parsedValue || 10);
// 로컬 상태도 최종값으로 업데이트
setLocalSize((prev) => ({ ...prev, width: newWidth }));
// size.width 경로로 업데이트 (격자 스냅 적용됨)
handleUpdate("size.width", newWidth);
}}
onKeyDown={(e) => {
// Enter 키로도 즉시 적용
if (e.key === "Enter") {
const newWidth = Math.max(10, parseInt((e.target as HTMLInputElement).value) || 10);
setLocalSize((prev) => ({ ...prev, width: newWidth }));
handleUpdate("size.width", newWidth);
(e.target as HTMLInputElement).blur();
}
}}
value={currentPosition.z || 1}
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
step="1"
value={localSize.height}
onChange={(e) => {
// 입력 중에는 로컬 상태만 업데이트
const value = e.target.value === "" ? "" : parseInt(e.target.value);
setLocalSize((prev) => ({ ...prev, height: value as number }));
}}
onBlur={(e) => {
// 포커스 아웃 시 실제 컴포넌트 업데이트
const rawValue = e.target.value;
const parsedValue = parseInt(rawValue);
const newHeight = Math.max(10, parsedValue || 10);
// 로컬 상태도 최종값으로 업데이트
setLocalSize((prev) => ({ ...prev, height: newHeight }));
// size.height 경로로 업데이트 (격자 스냅 적용됨)
handleUpdate("size.height", newHeight);
}}
onKeyDown={(e) => {
// Enter 키로도 즉시 적용
if (e.key === "Enter") {
const newHeight = Math.max(10, parseInt((e.target as HTMLInputElement).value) || 10);
setLocalSize((prev) => ({ ...prev, height: newHeight }));
handleUpdate("size.height", newHeight);
(e.target as HTMLInputElement).blur();
}
}}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
</div>

View File

@ -1,77 +1,205 @@
import { Position, Size } from "@/types/screen";
import { GridSettings } from "@/types/screen-management";
// 🎯 10px 고정 격자 시스템
const GRID_SIZE = 10; // 고정값
export interface GridInfo {
gridSize: number; // 항상 10px
columnWidth: number;
totalWidth: number;
totalHeight: number;
}
/**
* ()
*
*/
export function calculateGridInfo(
containerWidth: number,
containerHeight: number,
_gridSettings?: GridSettings, // 호환성 유지용 (사용 안 함)
gridSettings: GridSettings,
): GridInfo {
const { gap, padding } = gridSettings;
let { columns } = gridSettings;
// 🔥 최소 컬럼 너비를 보장하기 위한 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30; // 최소 컬럼 너비 30px
const availableWidth = containerWidth - padding * 2;
const maxPossibleColumns = Math.floor((availableWidth + gap) / (MIN_COLUMN_WIDTH + gap));
// 설정된 컬럼 수가 너무 많으면 자동으로 제한
if (columns > maxPossibleColumns) {
console.warn(
`⚠️ 격자 컬럼 수가 너무 많습니다. ${columns}개 → ${maxPossibleColumns}개로 자동 조정됨 (최소 컬럼 너비: ${MIN_COLUMN_WIDTH}px)`,
);
columns = Math.max(1, maxPossibleColumns);
}
// 격자 간격을 고려한 컬럼 너비 계산
const totalGaps = (columns - 1) * gap;
const columnWidth = (availableWidth - totalGaps) / columns;
return {
gridSize: GRID_SIZE,
columnWidth: Math.max(columnWidth, MIN_COLUMN_WIDTH),
totalWidth: containerWidth,
totalHeight: containerHeight,
};
}
/**
* 10px
*
*/
export function snapToGrid(position: Position, _gridInfo: GridInfo, gridSettings: GridSettings): Position {
export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position {
if (!gridSettings.snapToGrid) {
return position;
}
const { columnWidth } = gridInfo;
const { gap, padding } = gridSettings;
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
const cellWidth = columnWidth + gap;
const cellHeight = 10; // 행 높이 10px 단위로 고정
// 패딩을 제외한 상대 위치
const relativeX = position.x - padding;
const relativeY = position.y - padding;
// 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅)
const gridX = Math.round(relativeX / cellWidth);
const gridY = Math.round(relativeY / cellHeight);
// 실제 픽셀 위치로 변환
const snappedX = Math.max(padding, padding + gridX * cellWidth);
const snappedY = Math.max(padding, padding + gridY * cellHeight);
return {
x: Math.round(position.x / GRID_SIZE) * GRID_SIZE,
y: Math.round(position.y / GRID_SIZE) * GRID_SIZE,
x: snappedX,
y: snappedY,
z: position.z,
};
}
/**
* 10px
*
*/
export function snapSizeToGrid(size: Size, _gridInfo: GridInfo, gridSettings: GridSettings): Size {
export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size {
if (!gridSettings.snapToGrid) {
return size;
}
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 격자 단위로 너비 계산
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
let gridColumns = 1;
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
for (let cols = 1; cols <= gridSettings.columns; cols++) {
const targetWidth = cols * columnWidth + (cols - 1) * gap;
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
gridColumns = cols;
break;
}
gridColumns = cols;
}
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
// 높이는 10px 단위로 스냅
const rowHeight = 10;
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
console.log(
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
);
return {
width: Math.max(GRID_SIZE, Math.round(size.width / GRID_SIZE) * GRID_SIZE),
height: Math.max(GRID_SIZE, Math.round(size.height / GRID_SIZE) * GRID_SIZE),
width: Math.max(columnWidth, snappedWidth),
height: snappedHeight,
};
}
/**
* (10px )
*
*/
export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return columns * columnWidth + (columns - 1) * gap;
}
/**
* gridColumns
*/
export function updateSizeFromGridColumns(
component: { gridColumns?: number; size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (!component.gridColumns || component.gridColumns < 1) {
return component.size;
}
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
return {
width: newWidth,
height: component.size.height, // 높이는 유지
};
}
/**
* gridColumns를
*/
export function adjustGridColumnsFromSize(
component: { size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): number {
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
}
/**
*
*/
export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return Math.max(1, Math.round((width + gap) / (columnWidth + gap)));
}
/**
*
*/
export function generateGridLines(
containerWidth: number,
containerHeight: number,
_gridSettings?: GridSettings,
gridSettings: GridSettings,
): {
verticalLines: number[];
horizontalLines: number[];
} {
const { columns, gap, padding } = gridSettings;
const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings);
const { columnWidth } = gridInfo;
// 격자 셀 크기 (스냅 로직과 동일하게)
const cellWidth = columnWidth + gap;
const cellHeight = 10; // 행 높이 10px 단위로 고정
// 세로 격자선
const verticalLines: number[] = [];
for (let x = 0; x <= containerWidth; x += GRID_SIZE) {
verticalLines.push(x);
for (let i = 0; i <= columns; i++) {
const x = padding + i * cellWidth;
if (x <= containerWidth) {
verticalLines.push(x);
}
}
// 가로 격자선
const horizontalLines: number[] = [];
for (let y = 0; y <= containerHeight; y += GRID_SIZE) {
for (let y = padding; y < containerHeight; y += cellHeight) {
horizontalLines.push(y);
}
@ -114,21 +242,46 @@ export function alignGroupChildrenToGrid(
): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
return children.map((child) => {
console.log("🔧 alignGroupChildrenToGrid 시작:", {
childrenCount: children.length,
groupPosition,
gridInfo,
gridSettings,
});
return children.map((child, index) => {
console.log(`📐 자식 ${index + 1} 처리 중:`, {
childId: child.id,
originalPosition: child.position,
originalSize: child.size,
});
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = child.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
const snappedX = padding + columnIndex * (columnWidth + gap);
// 10px 단위로 스냅
const snappedX = Math.max(padding, Math.round((child.position.x - padding) / GRID_SIZE) * GRID_SIZE + padding);
const snappedY = Math.max(padding, Math.round((child.position.y - padding) / GRID_SIZE) * GRID_SIZE + padding);
// Y 좌표는 10px 단위로 스냅
const rowHeight = 10;
const effectiveY = child.position.y - padding;
const rowIndex = Math.round(effectiveY / rowHeight);
const snappedY = padding + rowIndex * rowHeight;
const snappedWidth = Math.max(GRID_SIZE, Math.round(child.size.width / GRID_SIZE) * GRID_SIZE);
const snappedHeight = Math.max(GRID_SIZE, Math.round(child.size.height / GRID_SIZE) * GRID_SIZE);
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(10, Math.round(child.size.height / rowHeight) * rowHeight);
return {
const snappedChild = {
...child,
position: {
x: snappedX,
y: snappedY,
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: child.position.z || 1,
},
size: {
@ -136,6 +289,26 @@ export function alignGroupChildrenToGrid(
height: snappedHeight,
},
};
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
childId: child.id,
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
widthInColumns,
originalX: child.position.x,
snappedX: snappedChild.position.x,
padding,
},
snappedPosition: snappedChild.position,
snappedSize: snappedChild.size,
deltaX: snappedChild.position.x - child.position.x,
deltaY: snappedChild.position.y - child.position.y,
});
return snappedChild;
});
}
@ -144,13 +317,19 @@ export function alignGroupChildrenToGrid(
*/
export function calculateOptimalGroupSize(
children: Array<{ position: Position; size: Size }>,
_gridInfo?: GridInfo,
_gridSettings?: GridSettings,
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (children.length === 0) {
return { width: GRID_SIZE * 20, height: GRID_SIZE * 10 };
return { width: gridInfo.columnWidth * 2, height: 10 * 4 };
}
console.log("📏 calculateOptimalGroupSize 시작:", {
childrenCount: children.length,
children: children.map((c) => ({ pos: c.position, size: c.size })),
});
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
const bounds = children.reduce(
(acc, child) => ({
minX: Math.min(acc.minX, child.position.x),
@ -161,38 +340,61 @@ export function calculateOptimalGroupSize(
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
console.log("📐 경계 계산:", bounds);
const contentWidth = bounds.maxX - bounds.minX;
const contentHeight = bounds.maxY - bounds.minY;
const padding = 16;
return {
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
const padding = 16; // 그룹 내부 여백
const groupSize = {
width: contentWidth + padding * 2,
height: contentHeight + padding * 2,
};
console.log("✅ 자연스러운 그룹 크기:", {
contentSize: { width: contentWidth, height: contentHeight },
withPadding: groupSize,
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
});
return groupSize;
}
/**
*
*/
export function normalizeGroupChildPositions(children: any[], _gridSettings?: GridSettings): any[] {
if (children.length === 0) return children;
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
console.log("🔄 normalizeGroupChildPositions 시작:", {
childrenCount: children.length,
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
});
// 모든 자식의 최소 위치 찾기
const minX = Math.min(...children.map((child) => child.position.x));
const minY = Math.min(...children.map((child) => child.position.y));
const padding = 16;
return children.map((child) => ({
console.log("📍 최소 위치:", { minX, minY });
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백)
const padding = 16;
const startX = padding;
const startY = padding;
const normalizedChildren = children.map((child) => ({
...child,
position: {
x: child.position.x - minX + padding,
y: child.position.y - minY + padding,
x: child.position.x - minX + startX,
y: child.position.y - minY + startY,
z: child.position.z || 1,
},
}));
}
// 🗑️ 제거된 함수들 (더 이상 필요 없음)
// - calculateWidthFromColumns
// - updateSizeFromGridColumns
// - adjustGridColumnsFromSize
// - calculateColumnsFromWidth
console.log("✅ 정규화 완료:", {
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
});
return normalizedChildren;
}

View File

@ -561,22 +561,21 @@ export interface LayoutData {
}
/**
* (10px )
*
*/
export interface GridSettings {
snapToGrid: boolean; // 격자 스냅 ON/OFF
showGrid?: boolean; // 격자 표시 여부
gridColor?: string; // 격자 선 색상
gridOpacity?: number; // 격자 선 투명도
// 🗑️ 제거된 속성들 (10px 고정으로 더 이상 필요 없음)
// - columns: 자동 계산 (해상도 ÷ 10px)
// - gap: 10px 고정
// - padding: 0px 고정
// - size: 10px 고정
// - enabled: showGrid로 대체
// - color: gridColor로 대체
// - opacity: gridOpacity로 대체
enabled: boolean;
size: number;
color: string;
opacity: number;
snapToGrid: boolean;
// gridUtils에서 필요한 속성들 추가
columns: number;
gap: number;
padding: number;
showGrid?: boolean;
gridColor?: string;
gridOpacity?: number;
}
/**