ERP-node/frontend/components/screen/ResponsiveGridRenderer.tsx

357 lines
10 KiB
TypeScript

"use client";
import React, { useMemo, useRef, useState, useEffect } from "react";
import { ComponentData } from "@/types/screen";
import { useResponsive } from "@/lib/hooks/useResponsive";
import { cn } from "@/lib/utils";
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-panel-layout",
"screen-split-panel",
"v2-split-line",
"flow-widget",
"v2-tab-container",
"tab-container",
"tabs-widget",
"v2-tabs-widget",
]);
const FLEX_GROW_TYPES = new Set([
"table-list",
"v2-table-list",
"split-panel-layout",
"split-panel-layout2",
"v2-split-panel-layout",
"screen-split-panel",
"v2-tab-container",
"tab-container",
"tabs-widget",
"v2-tabs-widget",
]);
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));
}
function getComponentTypeId(component: ComponentData): string {
const direct =
(component as any).componentType || (component as any).widgetType;
if (direct) return direct;
const url = (component as any).url;
if (url && typeof url === "string") {
const parts = url.split("/");
return parts[parts.length - 1];
}
return component.type || "";
}
function isButtonComponent(component: ComponentData): boolean {
return getComponentTypeId(component).includes("button");
}
function isFullWidthComponent(component: ComponentData): boolean {
return FULL_WIDTH_TYPES.has(getComponentTypeId(component));
}
function shouldFlexGrow(component: ComponentData): boolean {
return FLEX_GROW_TYPES.has(getComponentTypeId(component));
}
function getPercentageWidth(componentWidth: number, canvasWidth: number): number {
const pct = (componentWidth / canvasWidth) * 100;
return pct >= 95 ? 100 : pct;
}
function getRowGap(row: ComponentData[], canvasWidth: number): number {
if (row.length < 2) return 0;
const totalW = row.reduce((s, c) => s + (c.size?.width || 100), 0);
const gap = canvasWidth - totalW;
const cnt = row.length - 1;
if (gap <= 0 || cnt <= 0) return 8;
return Math.min(Math.max(Math.round(gap / cnt), 4), 24);
}
interface ProcessedRow {
type: "normal" | "fullwidth";
mainComponent?: ComponentData;
overlayComps: ComponentData[];
normalComps: ComponentData[];
}
/**
* 풀위드스 컴포넌트 + 오버레이 버튼.
* 원본 좌표 그대로 배치 → transform: scale 한 방으로 축소.
* 디자이너 미리보기와 동일한 원리.
*/
function FullWidthOverlayRow({
main,
overlayComps,
canvasWidth,
renderComponent,
}: {
main: ComponentData;
overlayComps: ComponentData[];
canvasWidth: number;
renderComponent: (component: ComponentData) => React.ReactNode;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerW, setContainerW] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w && w > 0) setContainerW(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const compFlexGrow = shouldFlexGrow(main);
const mainY = main.position.y;
const scale = containerW > 0 ? containerW / canvasWidth : 1;
const minButtonY = Math.min(...overlayComps.map((c) => c.position.y));
const rawYOffset = minButtonY - mainY;
const maxBtnH = Math.max(
...overlayComps.map((c) => c.size?.height || 40)
);
// 버튼 중심 보정: 스케일 축소 시 버튼이 작아지므로 중심 위치가 위로 올라감
// 디자이너와 동일한 중심-대-중심 정렬을 유지하기 위해 Y 오프셋 보정
const yOffset = rawYOffset + (maxBtnH / 2) * (1 - scale);
return (
<div
ref={containerRef}
className={cn(
"relative flex w-full flex-col",
compFlexGrow ? "min-h-0 flex-1" : "flex-shrink-0"
)}
>
<div
data-component-id={main.id}
data-component-type={getComponentTypeId(main)}
className="min-h-0 min-w-0"
style={{
width: "100%",
height: compFlexGrow ? "100%" : "auto",
minHeight: compFlexGrow ? "300px" : undefined,
flexGrow: 1,
}}
>
{renderComponent(main)}
</div>
{overlayComps.length > 0 && containerW > 0 && (
<div
className="pointer-events-none absolute left-0 z-10"
style={{
top: `${yOffset}px`,
width: `${canvasWidth}px`,
height: `${maxBtnH}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
{overlayComps.map((comp) => (
<div
key={comp.id}
data-component-id={comp.id}
data-component-type={getComponentTypeId(comp)}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y - minButtonY}px`,
width: `${comp.size?.width || 90}px`,
height: `${comp.size?.height || 40}px`,
}}
>
{renderComponent(comp)}
</div>
))}
</div>
)}
</div>
);
}
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: ProcessedRow[] = [];
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 (fullWidthComps.length > 0 && normalComps.length > 0) {
for (const fwComp of fullWidthComps) {
result.push({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: normalComps,
normalComps: [],
});
}
} else if (fullWidthComps.length > 0) {
for (const fwComp of fullWidthComps) {
result.push({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: [],
normalComps: [],
});
}
} else {
result.push({
type: "normal",
overlayComps: [],
normalComps,
});
}
}
return result;
}, [components]);
return (
<div
data-screen-runtime="true"
className="bg-background flex h-full w-full flex-col overflow-x-hidden"
style={{ minHeight: "200px" }}
>
{processedRows.map((processedRow, rowIndex) => {
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
return (
<FullWidthOverlayRow
key={`row-${rowIndex}`}
main={processedRow.mainComponent}
overlayComps={processedRow.overlayComps}
canvasWidth={canvasWidth}
renderComponent={renderComponent}
/>
);
}
const { normalComps } = processedRow;
const allButtons = normalComps.every((c) => isButtonComponent(c));
const gap = isMobile ? 8 : allButtons ? 8 : getRowGap(normalComps, canvasWidth);
return (
<div
key={`row-${rowIndex}`}
className={cn(
"flex w-full flex-shrink-0 flex-wrap overflow-hidden",
allButtons && "justify-end px-2 py-1"
)}
style={{ gap: `${gap}px` }}
>
{normalComps.map((component) => {
const typeId = getComponentTypeId(component);
const isButton = isButtonComponent(component);
const isFullWidth = isMobile && !isButton;
if (isButton) {
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
className="flex-shrink-0"
style={{
height: component.size?.height
? `${component.size.height}px`
: "40px",
}}
>
{renderComponent(component)}
</div>
);
}
const percentWidth = isFullWidth
? 100
: getPercentageWidth(component.size?.width || 100, canvasWidth);
const flexBasis = isFullWidth
? "100%"
: `calc(${percentWidth}% - ${gap}px)`;
const compFlexGrow = shouldFlexGrow(component);
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
className="min-w-0 flex-shrink-0 overflow-hidden"
style={{
width: isFullWidth ? "100%" : undefined,
flexBasis,
flexGrow: isFullWidth || compFlexGrow ? 1 : 0,
minWidth: isMobile ? "100%" : undefined,
height: component.size?.height
? `${component.size.height}px`
: "auto",
}}
>
{renderComponent(component)}
</div>
);
})}
</div>
);
})}
</div>
);
}
export default ResponsiveGridRenderer;