241 lines
6.7 KiB
TypeScript
241 lines
6.7 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useMemo } from "react";
|
||
|
|
import { ComponentData } from "@/types/screen";
|
||
|
|
import { useResponsive } from "@/lib/hooks/useResponsive";
|
||
|
|
|
||
|
|
interface ResponsiveGridRendererProps {
|
||
|
|
components: ComponentData[];
|
||
|
|
canvasWidth: number;
|
||
|
|
canvasHeight: number;
|
||
|
|
renderComponent: (component: ComponentData) => React.ReactNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 전체 행을 차지해야 하는 컴포넌트 타입
|
||
|
|
const FULL_WIDTH_TYPES = new Set([
|
||
|
|
"table-list",
|
||
|
|
"v2-table-list",
|
||
|
|
"table-search-widget",
|
||
|
|
"v2-table-search-widget",
|
||
|
|
"conditional-container",
|
||
|
|
"split-panel-layout",
|
||
|
|
"split-panel-layout2",
|
||
|
|
"v2-split-line",
|
||
|
|
"flow-widget",
|
||
|
|
"v2-tab-container",
|
||
|
|
"tab-container",
|
||
|
|
]);
|
||
|
|
|
||
|
|
// 높이를 auto로 처리해야 하는 컴포넌트 타입
|
||
|
|
const AUTO_HEIGHT_TYPES = new Set([
|
||
|
|
"table-list",
|
||
|
|
"v2-table-list",
|
||
|
|
"table-search-widget",
|
||
|
|
"v2-table-search-widget",
|
||
|
|
"conditional-container",
|
||
|
|
"flow-widget",
|
||
|
|
"v2-tab-container",
|
||
|
|
"tab-container",
|
||
|
|
"split-panel-layout",
|
||
|
|
"split-panel-layout2",
|
||
|
|
]);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Y좌표 기준으로 컴포넌트를 행(row) 단위로 그룹핑
|
||
|
|
* - Y값 차이가 threshold 이내면 같은 행으로 판정
|
||
|
|
* - 같은 행 안에서는 X좌표 순으로 정렬
|
||
|
|
*/
|
||
|
|
function groupComponentsIntoRows(
|
||
|
|
components: ComponentData[],
|
||
|
|
threshold: number = 30
|
||
|
|
): ComponentData[][] {
|
||
|
|
if (components.length === 0) return [];
|
||
|
|
|
||
|
|
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
|
||
|
|
|
||
|
|
const rows: ComponentData[][] = [];
|
||
|
|
let currentRow: ComponentData[] = [];
|
||
|
|
let currentRowY = -Infinity;
|
||
|
|
|
||
|
|
for (const comp of sorted) {
|
||
|
|
if (comp.position.y - currentRowY > threshold) {
|
||
|
|
if (currentRow.length > 0) rows.push(currentRow);
|
||
|
|
currentRow = [comp];
|
||
|
|
currentRowY = comp.position.y;
|
||
|
|
} else {
|
||
|
|
currentRow.push(comp);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (currentRow.length > 0) rows.push(currentRow);
|
||
|
|
|
||
|
|
return rows.map((row) =>
|
||
|
|
row.sort((a, b) => a.position.x - b.position.x)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 컴포넌트의 유형 식별 (componentType, componentId, widgetType 등)
|
||
|
|
*/
|
||
|
|
function getComponentTypeId(component: ComponentData): string {
|
||
|
|
return (
|
||
|
|
(component as any).componentType ||
|
||
|
|
(component as any).componentId ||
|
||
|
|
(component as any).widgetType ||
|
||
|
|
component.type ||
|
||
|
|
""
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 전체 행을 차지해야 하는 컴포넌트인지 판정
|
||
|
|
*/
|
||
|
|
function isFullWidthComponent(component: ComponentData): boolean {
|
||
|
|
const typeId = getComponentTypeId(component);
|
||
|
|
return FULL_WIDTH_TYPES.has(typeId);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 높이를 auto로 처리해야 하는 컴포넌트인지 판정
|
||
|
|
*/
|
||
|
|
function shouldAutoHeight(component: ComponentData): boolean {
|
||
|
|
const typeId = getComponentTypeId(component);
|
||
|
|
return AUTO_HEIGHT_TYPES.has(typeId);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 컴포넌트 너비를 캔버스 대비 비율(%)로 변환
|
||
|
|
*/
|
||
|
|
function getPercentageWidth(
|
||
|
|
componentWidth: number,
|
||
|
|
canvasWidth: number
|
||
|
|
): number {
|
||
|
|
const percentage = (componentWidth / canvasWidth) * 100;
|
||
|
|
if (percentage >= 95) return 100;
|
||
|
|
return percentage;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 행 내 컴포넌트 사이의 수평 갭(px)을 비율 기반으로 추정
|
||
|
|
*/
|
||
|
|
function getRowGap(row: ComponentData[], canvasWidth: number): number {
|
||
|
|
if (row.length < 2) return 0;
|
||
|
|
const totalComponentWidth = row.reduce(
|
||
|
|
(sum, c) => sum + (c.size?.width || 100),
|
||
|
|
0
|
||
|
|
);
|
||
|
|
const totalGap = canvasWidth - totalComponentWidth;
|
||
|
|
const gapCount = row.length - 1;
|
||
|
|
if (totalGap <= 0 || gapCount <= 0) return 8;
|
||
|
|
const gapPx = totalGap / gapCount;
|
||
|
|
return Math.min(Math.max(Math.round(gapPx), 4), 24);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ResponsiveGridRenderer({
|
||
|
|
components,
|
||
|
|
canvasWidth,
|
||
|
|
canvasHeight,
|
||
|
|
renderComponent,
|
||
|
|
}: ResponsiveGridRendererProps) {
|
||
|
|
const { isMobile } = useResponsive();
|
||
|
|
|
||
|
|
// 전체 행을 차지하는 컴포넌트는 별도 행으로 분리
|
||
|
|
const processedRows = useMemo(() => {
|
||
|
|
const topLevel = components.filter((c) => !c.parentId);
|
||
|
|
const rows = groupComponentsIntoRows(topLevel);
|
||
|
|
|
||
|
|
const result: ComponentData[][] = [];
|
||
|
|
for (const row of rows) {
|
||
|
|
// 전체 너비 컴포넌트는 독립 행으로 분리
|
||
|
|
const fullWidthComps: ComponentData[] = [];
|
||
|
|
const normalComps: ComponentData[] = [];
|
||
|
|
|
||
|
|
for (const comp of row) {
|
||
|
|
if (isFullWidthComponent(comp)) {
|
||
|
|
fullWidthComps.push(comp);
|
||
|
|
} else {
|
||
|
|
normalComps.push(comp);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 일반 컴포넌트 행 먼저
|
||
|
|
if (normalComps.length > 0) {
|
||
|
|
result.push(normalComps);
|
||
|
|
}
|
||
|
|
// 전체 너비 컴포넌트는 각각 독립 행
|
||
|
|
for (const comp of fullWidthComps) {
|
||
|
|
result.push([comp]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}, [components]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
data-screen-runtime="true"
|
||
|
|
className="bg-background flex w-full flex-col"
|
||
|
|
style={{ minHeight: "200px" }}
|
||
|
|
>
|
||
|
|
{processedRows.map((row, rowIndex) => {
|
||
|
|
const isSingleFullWidth =
|
||
|
|
row.length === 1 && isFullWidthComponent(row[0]);
|
||
|
|
const gap = isMobile ? 8 : getRowGap(row, canvasWidth);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={`row-${rowIndex}`}
|
||
|
|
className="flex w-full flex-wrap"
|
||
|
|
style={{ gap: isSingleFullWidth ? 0 : `${gap}px` }}
|
||
|
|
>
|
||
|
|
{row.map((component) => {
|
||
|
|
const typeId = getComponentTypeId(component);
|
||
|
|
const isFullWidth =
|
||
|
|
isMobile || isFullWidthComponent(component);
|
||
|
|
const percentWidth = isFullWidth
|
||
|
|
? 100
|
||
|
|
: getPercentageWidth(
|
||
|
|
component.size?.width || 100,
|
||
|
|
canvasWidth
|
||
|
|
);
|
||
|
|
|
||
|
|
const autoHeight = shouldAutoHeight(component);
|
||
|
|
const height = autoHeight
|
||
|
|
? "auto"
|
||
|
|
: component.size?.height
|
||
|
|
? `${component.size.height}px`
|
||
|
|
: "auto";
|
||
|
|
|
||
|
|
// 모바일에서는 100%, 데스크톱에서는 비율 기반
|
||
|
|
// gap을 고려한 flex-basis 계산
|
||
|
|
const flexBasis = isFullWidth
|
||
|
|
? "100%"
|
||
|
|
: `calc(${percentWidth}% - ${gap}px)`;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={component.id}
|
||
|
|
data-component-id={component.id}
|
||
|
|
data-component-type={typeId}
|
||
|
|
className="flex-shrink-0"
|
||
|
|
style={{
|
||
|
|
width: isFullWidth ? "100%" : undefined,
|
||
|
|
flexBasis: isFullWidth ? "100%" : flexBasis,
|
||
|
|
flexGrow: isFullWidth ? 1 : 0,
|
||
|
|
minWidth: isMobile ? "100%" : undefined,
|
||
|
|
height,
|
||
|
|
minHeight: autoHeight ? undefined : height,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{renderComponent(component)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default ResponsiveGridRenderer;
|