357 lines
10 KiB
TypeScript
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;
|