2025-10-16 18:16:57 +09:00
|
|
|
/**
|
|
|
|
|
* 반응형 레이아웃 엔진
|
|
|
|
|
*
|
|
|
|
|
* 절대 위치로 배치된 컴포넌트들을 반응형 그리드로 변환
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import React, { useMemo } from "react";
|
|
|
|
|
import { ComponentData } from "@/types/screen-management";
|
|
|
|
|
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
|
|
|
|
import { generateSmartDefaults, ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults";
|
|
|
|
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
|
|
|
|
|
2025-10-17 15:31:23 +09:00
|
|
|
export interface ResponsiveLayoutEngineProps {
|
2025-10-16 18:16:57 +09:00
|
|
|
components: ComponentData[];
|
|
|
|
|
breakpoint: Breakpoint;
|
|
|
|
|
containerWidth: number;
|
|
|
|
|
screenWidth?: number;
|
2025-10-17 10:12:41 +09:00
|
|
|
preserveYPosition?: boolean; // true: Y좌표 유지 (하이브리드), false: 16px 간격 (반응형)
|
2025-10-17 15:31:23 +09:00
|
|
|
formData?: Record<string, unknown>;
|
|
|
|
|
onFormDataChange?: (fieldName: string, value: unknown) => void;
|
|
|
|
|
screenInfo?: { id: number; tableName?: string };
|
2025-10-16 18:16:57 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 반응형 레이아웃 엔진
|
|
|
|
|
*
|
|
|
|
|
* 변환 로직:
|
|
|
|
|
* 1. Y 위치 기준으로 행(row)으로 그룹화
|
|
|
|
|
* 2. 각 행 내에서 X 위치 기준으로 정렬
|
|
|
|
|
* 3. 반응형 설정 적용 (order, gridColumns, hide)
|
|
|
|
|
* 4. CSS Grid로 렌더링
|
|
|
|
|
*/
|
|
|
|
|
export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
|
|
|
|
|
components,
|
|
|
|
|
breakpoint,
|
|
|
|
|
containerWidth,
|
|
|
|
|
screenWidth = 1920,
|
2025-10-17 10:12:41 +09:00
|
|
|
preserveYPosition = false, // 기본값: 반응형 모드 (16px 간격)
|
2025-10-17 15:31:23 +09:00
|
|
|
formData,
|
|
|
|
|
onFormDataChange,
|
|
|
|
|
screenInfo,
|
2025-10-16 18:16:57 +09:00
|
|
|
}) => {
|
|
|
|
|
// 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화
|
|
|
|
|
const rows = useMemo(() => {
|
|
|
|
|
const sortedComponents = [...components].sort((a, b) => a.position.y - b.position.y);
|
|
|
|
|
|
|
|
|
|
const rows: ComponentData[][] = [];
|
|
|
|
|
let currentRow: ComponentData[] = [];
|
|
|
|
|
let currentRowY = 0;
|
|
|
|
|
const ROW_THRESHOLD = 150; // 같은 행으로 간주할 Y 오차 범위 (px) - 여유있게 설정
|
|
|
|
|
|
|
|
|
|
sortedComponents.forEach((comp) => {
|
|
|
|
|
if (currentRow.length === 0) {
|
|
|
|
|
currentRow.push(comp);
|
|
|
|
|
currentRowY = comp.position.y;
|
|
|
|
|
} else if (Math.abs(comp.position.y - currentRowY) < ROW_THRESHOLD) {
|
|
|
|
|
currentRow.push(comp);
|
|
|
|
|
} else {
|
|
|
|
|
rows.push(currentRow);
|
|
|
|
|
currentRow = [comp];
|
|
|
|
|
currentRowY = comp.position.y;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (currentRow.length > 0) {
|
|
|
|
|
rows.push(currentRow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return rows;
|
|
|
|
|
}, [components]);
|
|
|
|
|
|
|
|
|
|
// 2단계: 각 행 내에서 X 위치 기준으로 정렬
|
|
|
|
|
const sortedRows = useMemo(() => {
|
|
|
|
|
return rows.map((row) => [...row].sort((a, b) => a.position.x - b.position.x));
|
|
|
|
|
}, [rows]);
|
|
|
|
|
|
|
|
|
|
// 3단계: 반응형 설정 적용
|
|
|
|
|
const responsiveComponents = useMemo(() => {
|
|
|
|
|
const result = sortedRows.flatMap((row, rowIndex) =>
|
|
|
|
|
row.map((comp, compIndex) => {
|
2025-10-17 15:31:23 +09:00
|
|
|
// 컴포넌트에 gridColumns가 이미 설정되어 있으면 그 값 사용
|
|
|
|
|
if ((comp as any).gridColumns !== undefined) {
|
|
|
|
|
return {
|
|
|
|
|
...comp,
|
|
|
|
|
responsiveDisplay: {
|
|
|
|
|
gridColumns: (comp as any).gridColumns,
|
|
|
|
|
order: compIndex + 1,
|
|
|
|
|
hide: false,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 18:16:57 +09:00
|
|
|
// 반응형 설정이 없으면 자동 생성
|
|
|
|
|
const compWithConfig = ensureResponsiveConfig(comp, screenWidth);
|
|
|
|
|
|
|
|
|
|
// 현재 브레이크포인트의 설정 가져오기 (같은 행의 컴포넌트 개수 전달)
|
|
|
|
|
const config = compWithConfig.responsiveConfig!.useSmartDefaults
|
|
|
|
|
? generateSmartDefaults(comp, screenWidth, row.length)[breakpoint]
|
|
|
|
|
: compWithConfig.responsiveConfig!.responsive?.[breakpoint];
|
|
|
|
|
|
|
|
|
|
const finalConfig = config || generateSmartDefaults(comp, screenWidth, row.length)[breakpoint];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...compWithConfig,
|
|
|
|
|
responsiveDisplay: finalConfig,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}, [sortedRows, breakpoint, screenWidth]);
|
|
|
|
|
|
|
|
|
|
// 4단계: 필터링 및 정렬
|
|
|
|
|
const visibleComponents = useMemo(() => {
|
|
|
|
|
return responsiveComponents
|
|
|
|
|
.filter((comp) => !comp.responsiveDisplay?.hide)
|
|
|
|
|
.sort((a, b) => (a.responsiveDisplay?.order || 0) - (b.responsiveDisplay?.order || 0));
|
|
|
|
|
}, [responsiveComponents]);
|
|
|
|
|
|
|
|
|
|
const gridColumns = BREAKPOINTS[breakpoint].columns;
|
|
|
|
|
|
|
|
|
|
// 각 행의 Y 위치를 추적
|
|
|
|
|
const rowsWithYPosition = useMemo(() => {
|
|
|
|
|
return sortedRows.map((row) => ({
|
|
|
|
|
components: row,
|
|
|
|
|
yPosition: Math.min(...row.map((c) => c.position.y)), // 행의 최소 Y 위치
|
|
|
|
|
}));
|
|
|
|
|
}, [sortedRows]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="responsive-container w-full" style={{ position: "relative" }}>
|
|
|
|
|
{rowsWithYPosition.map((row, rowIndex) => {
|
|
|
|
|
const rowComponents = visibleComponents.filter((vc) => row.components.some((rc) => rc.id === vc.id));
|
|
|
|
|
|
2025-10-17 10:12:41 +09:00
|
|
|
// Y 좌표 계산: preserveYPosition에 따라 다르게 처리
|
|
|
|
|
let marginTop: string;
|
|
|
|
|
if (preserveYPosition) {
|
|
|
|
|
// 하이브리드 모드: 원래 Y 좌표 간격 유지
|
|
|
|
|
if (rowIndex === 0) {
|
|
|
|
|
marginTop = `${row.yPosition}px`;
|
|
|
|
|
} else {
|
|
|
|
|
const prevRowY = rowsWithYPosition[rowIndex - 1].yPosition;
|
|
|
|
|
const actualGap = row.yPosition - prevRowY;
|
|
|
|
|
marginTop = `${actualGap}px`;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2025-10-17 16:39:46 +09:00
|
|
|
// 반응형 모드: 첫 번째는 맨 위부터 시작 (0px), 나머지는 16px 고정 간격
|
|
|
|
|
marginTop = rowIndex === 0 ? "0px" : "16px";
|
2025-10-17 10:12:41 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-16 18:16:57 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={`row-${rowIndex}`}
|
|
|
|
|
className="responsive-grid w-full"
|
|
|
|
|
style={{
|
|
|
|
|
display: "grid",
|
|
|
|
|
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
|
|
|
|
gap: "16px",
|
|
|
|
|
padding: "0 16px",
|
2025-10-17 10:12:41 +09:00
|
|
|
marginTop,
|
2025-10-17 15:31:23 +09:00
|
|
|
alignItems: "start", // 각 아이템이 원래 높이 유지
|
2025-10-16 18:16:57 +09:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{rowComponents.map((comp) => (
|
|
|
|
|
<div
|
|
|
|
|
key={comp.id}
|
|
|
|
|
className="responsive-grid-item"
|
|
|
|
|
style={{
|
|
|
|
|
gridColumn: `span ${comp.responsiveDisplay?.gridColumns || gridColumns}`,
|
2025-10-17 15:31:23 +09:00
|
|
|
height: "auto", // 자동 높이
|
2025-10-16 18:16:57 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2025-10-17 15:31:23 +09:00
|
|
|
<DynamicComponentRenderer
|
|
|
|
|
component={comp}
|
|
|
|
|
isPreview={true}
|
|
|
|
|
isDesignMode={false}
|
|
|
|
|
isInteractive={true}
|
|
|
|
|
formData={formData}
|
|
|
|
|
onFormDataChange={onFormDataChange}
|
|
|
|
|
screenId={screenInfo?.id}
|
|
|
|
|
tableName={screenInfo?.tableName}
|
|
|
|
|
/>
|
2025-10-16 18:16:57 +09:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|