538 lines
19 KiB
TypeScript
538 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo } from "react";
|
|
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
|
import {
|
|
Database,
|
|
Type,
|
|
Hash,
|
|
List,
|
|
AlignLeft,
|
|
CheckSquare,
|
|
Radio,
|
|
Calendar,
|
|
Code,
|
|
Building,
|
|
File,
|
|
} from "lucide-react";
|
|
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
|
|
|
// 컴포넌트 렌더러들 자동 등록
|
|
import "@/lib/registry/components";
|
|
|
|
interface RealtimePreviewProps {
|
|
component: ComponentData;
|
|
isSelected?: boolean;
|
|
isDesignMode?: boolean; // 편집 모드 여부
|
|
onClick?: (e?: React.MouseEvent) => void;
|
|
onDoubleClick?: (e?: React.MouseEvent) => void; // 더블클릭 핸들러 추가
|
|
onDragStart?: (e: React.DragEvent) => void;
|
|
onDragEnd?: () => void;
|
|
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
|
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
|
selectedScreen?: any;
|
|
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
|
|
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
|
|
onConfigChange?: (config: any) => void; // 설정 변경 핸들러
|
|
|
|
// 버튼 액션을 위한 props
|
|
screenId?: number;
|
|
tableName?: string;
|
|
userId?: string; // 🆕 현재 사용자 ID
|
|
userName?: string; // 🆕 현재 사용자 이름
|
|
companyCode?: string; // 🆕 현재 사용자의 회사 코드
|
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리 스코프용)
|
|
selectedRowsData?: any[];
|
|
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
|
flowSelectedData?: any[];
|
|
flowSelectedStepId?: number | null;
|
|
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
|
refreshKey?: number;
|
|
onRefresh?: () => void;
|
|
flowRefreshKey?: number;
|
|
onFlowRefresh?: () => void;
|
|
|
|
// 폼 데이터 관련 props
|
|
formData?: Record<string, any>;
|
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
|
|
|
// 테이블 정렬 정보
|
|
sortBy?: string;
|
|
sortOrder?: "asc" | "desc";
|
|
columnOrder?: string[];
|
|
|
|
// 🆕 조건부 컨테이너 높이 변화 콜백
|
|
onHeightChange?: (componentId: string, newHeight: number) => void;
|
|
}
|
|
|
|
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
|
const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
|
|
if (!widgetType) return <Type className="h-3 w-3" />;
|
|
|
|
const iconMap: Record<string, React.ReactNode> = {
|
|
text: <span className="text-xs">Aa</span>,
|
|
number: <Hash className="h-3 w-3" />,
|
|
decimal: <Hash className="h-3 w-3" />,
|
|
date: <Calendar className="h-3 w-3" />,
|
|
datetime: <Calendar className="h-3 w-3" />,
|
|
select: <List className="h-3 w-3" />,
|
|
dropdown: <List className="h-3 w-3" />,
|
|
textarea: <AlignLeft className="h-3 w-3" />,
|
|
boolean: <CheckSquare className="h-3 w-3" />,
|
|
checkbox: <CheckSquare className="h-3 w-3" />,
|
|
radio: <Radio className="h-3 w-3" />,
|
|
code: <Code className="h-3 w-3" />,
|
|
entity: <Building className="h-3 w-3" />,
|
|
file: <File className="h-3 w-3" />,
|
|
email: <span className="text-xs">@</span>,
|
|
tel: <span className="text-xs">☎</span>,
|
|
button: <span className="text-xs">BTN</span>,
|
|
};
|
|
|
|
return iconMap[widgetType] || <Type className="h-3 w-3" />;
|
|
};
|
|
|
|
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|
component,
|
|
isSelected = false,
|
|
isDesignMode = true, // 기본값은 편집 모드
|
|
onClick,
|
|
onDoubleClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
onGroupToggle,
|
|
children,
|
|
selectedScreen,
|
|
onZoneComponentDrop,
|
|
onZoneClick,
|
|
onConfigChange,
|
|
screenId,
|
|
tableName,
|
|
userId, // 🆕 사용자 ID
|
|
userName, // 🆕 사용자 이름
|
|
companyCode, // 🆕 회사 코드
|
|
menuObjid, // 🆕 메뉴 OBJID
|
|
selectedRowsData,
|
|
onSelectedRowsChange,
|
|
flowSelectedData,
|
|
flowSelectedStepId,
|
|
onFlowSelectedDataChange,
|
|
refreshKey,
|
|
onRefresh,
|
|
sortBy,
|
|
sortOrder,
|
|
columnOrder,
|
|
flowRefreshKey,
|
|
onFlowRefresh,
|
|
formData,
|
|
onFormDataChange,
|
|
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
|
}) => {
|
|
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
|
const lastUpdatedHeight = React.useRef<number | null>(null);
|
|
|
|
// 플로우 위젯의 실제 높이 측정
|
|
React.useEffect(() => {
|
|
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
|
|
|
|
if (isFlowWidget && contentRef.current) {
|
|
const measureHeight = () => {
|
|
if (contentRef.current) {
|
|
// getBoundingClientRect()로 실제 렌더링된 높이 측정
|
|
const rect = contentRef.current.getBoundingClientRect();
|
|
const measured = rect.height;
|
|
|
|
// scrollHeight도 함께 확인하여 더 큰 값 사용
|
|
const scrollHeight = contentRef.current.scrollHeight;
|
|
const rawHeight = Math.max(measured, scrollHeight);
|
|
|
|
// 40px 단위로 올림
|
|
const finalHeight = Math.ceil(rawHeight / 40) * 40;
|
|
|
|
if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) {
|
|
setActualHeight(finalHeight);
|
|
|
|
// 컴포넌트의 실제 size.height도 업데이트 (중복 업데이트 방지)
|
|
if (onConfigChange && finalHeight !== lastUpdatedHeight.current && finalHeight !== component.size?.height) {
|
|
lastUpdatedHeight.current = finalHeight;
|
|
// size는 별도 속성이므로 직접 업데이트
|
|
const event = new CustomEvent("updateComponentSize", {
|
|
detail: {
|
|
componentId: component.id,
|
|
height: finalHeight,
|
|
},
|
|
});
|
|
window.dispatchEvent(event);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// 초기 측정 (렌더링 완료 후)
|
|
const initialTimer = setTimeout(() => {
|
|
measureHeight();
|
|
}, 100);
|
|
|
|
// 추가 측정 (데이터 로딩 완료 대기)
|
|
const delayedTimer = setTimeout(() => {
|
|
measureHeight();
|
|
}, 500);
|
|
|
|
// 스텝 클릭 등으로 높이가 변경될 때를 위한 추가 측정
|
|
const extendedTimer = setTimeout(() => {
|
|
measureHeight();
|
|
}, 1000);
|
|
|
|
// ResizeObserver로 크기 변화 감지 (스텝 클릭 시 데이터 테이블 펼쳐짐)
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
// 약간의 지연을 두고 측정 (DOM 업데이트 완료 대기)
|
|
setTimeout(() => {
|
|
measureHeight();
|
|
}, 100);
|
|
});
|
|
|
|
resizeObserver.observe(contentRef.current);
|
|
|
|
return () => {
|
|
clearTimeout(initialTimer);
|
|
clearTimeout(delayedTimer);
|
|
clearTimeout(extendedTimer);
|
|
resizeObserver.disconnect();
|
|
};
|
|
}
|
|
}, [component.type, component.id, actualHeight, component.size?.height, onConfigChange]);
|
|
const { id, type, position, size, style: componentStyle } = component;
|
|
|
|
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
|
|
const selectionStyle = isSelected
|
|
? {
|
|
outline: "2px solid rgb(59, 130, 246)",
|
|
outlineOffset: "0px", // 스크롤 방지를 위해 0으로 설정
|
|
zIndex: 20,
|
|
}
|
|
: {};
|
|
|
|
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
|
// 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정)
|
|
const getWidth = () => {
|
|
// 모든 컴포넌트는 size.width 픽셀 사용 (table-list 포함)
|
|
const width = `${size?.width || 100}px`;
|
|
return width;
|
|
};
|
|
|
|
const getHeight = () => {
|
|
// 🆕 조건부 컨테이너는 높이를 자동으로 설정 (내용물에 따라 자동 조정)
|
|
const isConditionalContainer = (component as any).componentType === "conditional-container";
|
|
if (isConditionalContainer && !isDesignMode) {
|
|
return "auto"; // 런타임에서는 내용물 높이에 맞춤
|
|
}
|
|
|
|
// 플로우 위젯의 경우 측정된 높이 사용
|
|
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
|
|
if (isFlowWidget && actualHeight) {
|
|
return `${actualHeight}px`;
|
|
}
|
|
|
|
// 1순위: style.height가 있으면 우선 사용 (문자열 그대로 또는 숫자+px)
|
|
if (componentStyle?.height) {
|
|
return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height;
|
|
}
|
|
|
|
// 2순위: size.height (픽셀)
|
|
if (component.componentConfig?.type === "table-list") {
|
|
return `${Math.max(size?.height || 200, 200)}px`;
|
|
}
|
|
|
|
// size.height가 있으면 그대로 사용, 없으면 최소 10px
|
|
return `${size?.height || 10}px`;
|
|
};
|
|
|
|
// layout 타입 컴포넌트인지 확인
|
|
const isLayoutComponent = component.type === "layout" || (component.componentConfig as any)?.type?.includes("layout");
|
|
|
|
// layout 컴포넌트는 component 객체에 style.height 추가
|
|
const enhancedComponent = isLayoutComponent
|
|
? {
|
|
...component,
|
|
style: {
|
|
...component.style,
|
|
height: getHeight(),
|
|
},
|
|
}
|
|
: component;
|
|
|
|
// 🆕 분할 패널 리사이즈 Context
|
|
const splitPanelContext = useSplitPanel();
|
|
|
|
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
|
const componentType = (component as any).componentType || "";
|
|
const componentId = (component as any).componentId || "";
|
|
const widgetType = (component as any).widgetType || "";
|
|
|
|
const isButtonComponent =
|
|
(type === "widget" && widgetType === "button") ||
|
|
(type === "component" &&
|
|
(["button-primary", "button-secondary"].includes(componentType) ||
|
|
["button-primary", "button-secondary"].includes(componentId)));
|
|
|
|
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
|
|
const initialPanelRatioRef = React.useRef<number | null>(null);
|
|
const initialPanelIdRef = React.useRef<string | null>(null);
|
|
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
|
|
const isInLeftPanelRef = React.useRef<boolean | null>(null);
|
|
|
|
// 🆕 분할 패널 위 버튼 위치 자동 조정
|
|
const calculateButtonPosition = () => {
|
|
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
|
|
const isSplitPanelComponent =
|
|
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
|
|
|
|
if (!isButtonComponent || isSplitPanelComponent) {
|
|
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
|
}
|
|
|
|
const componentWidth = size?.width || 100;
|
|
const componentHeight = size?.height || 40;
|
|
|
|
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
|
|
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
|
|
|
|
// 분할 패널 위에 없으면 기준점 초기화
|
|
if (!overlap) {
|
|
if (initialPanelIdRef.current !== null) {
|
|
initialPanelRatioRef.current = null;
|
|
initialPanelIdRef.current = null;
|
|
isInLeftPanelRef.current = null;
|
|
}
|
|
return {
|
|
adjustedPositionX: position.x,
|
|
isOnSplitPanel: false,
|
|
isDraggingSplitPanel: false,
|
|
};
|
|
}
|
|
|
|
const { panel } = overlap;
|
|
|
|
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
|
|
if (initialPanelIdRef.current !== overlap.panelId) {
|
|
initialPanelRatioRef.current = panel.leftWidthPercent;
|
|
initialPanelIdRef.current = overlap.panelId;
|
|
|
|
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
|
|
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
|
|
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
|
const componentCenterX = position.x + componentWidth / 2;
|
|
const relativeX = componentCenterX - panel.x;
|
|
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
|
|
|
|
isInLeftPanelRef.current = wasInLeftPanel;
|
|
console.log("📌 [버튼 기준점 설정]:", {
|
|
componentId: component.id,
|
|
panelId: overlap.panelId,
|
|
initialRatio: panel.leftWidthPercent,
|
|
isInLeftPanel: wasInLeftPanel,
|
|
buttonCenterX: componentCenterX,
|
|
leftPanelWidth: initialLeftPanelWidth,
|
|
});
|
|
}
|
|
|
|
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준)
|
|
if (!isInLeftPanelRef.current) {
|
|
return {
|
|
adjustedPositionX: position.x,
|
|
isOnSplitPanel: true,
|
|
isDraggingSplitPanel: panel.isDragging,
|
|
};
|
|
}
|
|
|
|
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
|
|
const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent;
|
|
|
|
// 기준 비율 대비 현재 비율로 분할선 위치 계산
|
|
const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치
|
|
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치
|
|
|
|
// 분할선 이동량 (px)
|
|
const dividerDelta = currentDividerX - baseDividerX;
|
|
|
|
// 변화가 없으면 원래 위치 반환
|
|
if (Math.abs(dividerDelta) < 1) {
|
|
return {
|
|
adjustedPositionX: position.x,
|
|
isOnSplitPanel: true,
|
|
isDraggingSplitPanel: panel.isDragging,
|
|
};
|
|
}
|
|
|
|
// 🆕 버튼도 분할선과 같은 양만큼 이동
|
|
// 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동
|
|
const adjustedX = position.x + dividerDelta;
|
|
|
|
console.log("📍 [버튼 위치 조정]:", {
|
|
componentId: component.id,
|
|
originalX: position.x,
|
|
adjustedX,
|
|
dividerDelta,
|
|
baseRatio,
|
|
currentRatio: panel.leftWidthPercent,
|
|
baseDividerX,
|
|
currentDividerX,
|
|
isDragging: panel.isDragging,
|
|
});
|
|
|
|
return {
|
|
adjustedPositionX: adjustedX,
|
|
isOnSplitPanel: true,
|
|
isDraggingSplitPanel: panel.isDragging,
|
|
};
|
|
};
|
|
|
|
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
|
|
|
|
const baseStyle = {
|
|
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
|
|
top: `${position.y}px`,
|
|
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
|
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
|
|
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
|
|
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
|
right: undefined,
|
|
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
|
transition:
|
|
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
|
};
|
|
|
|
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
|
// if (component.id && isSelected) {
|
|
// console.log("📐 RealtimePreview baseStyle:", {
|
|
// componentId: component.id,
|
|
// componentType: (component as any).componentType || component.type,
|
|
// sizeWidth: size?.width,
|
|
// sizeHeight: size?.height,
|
|
// });
|
|
// }
|
|
|
|
// 🔍 DOM 렌더링 후 실제 크기 측정
|
|
const innerDivRef = React.useRef<HTMLDivElement>(null);
|
|
const outerDivRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (outerDivRef.current && innerDivRef.current) {
|
|
const outerRect = outerDivRef.current.getBoundingClientRect();
|
|
const innerRect = innerDivRef.current.getBoundingClientRect();
|
|
// 크기 측정 완료
|
|
}
|
|
}, [id, component.label, (component as any).gridColumns, baseStyle.width]);
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onClick?.(e);
|
|
};
|
|
|
|
const handleDoubleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onDoubleClick?.(e);
|
|
};
|
|
|
|
const handleDragStart = (e: React.DragEvent) => {
|
|
e.stopPropagation();
|
|
onDragStart?.(e);
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
onDragEnd?.();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={outerDivRef}
|
|
id={`component-${id}`}
|
|
data-component-id={id}
|
|
className="absolute cursor-pointer transition-all duration-200 ease-out"
|
|
style={{ ...baseStyle, ...selectionStyle }}
|
|
onClick={handleClick}
|
|
onDoubleClick={handleDoubleClick}
|
|
draggable={isDesignMode} // 디자인 모드에서만 드래그 가능
|
|
onDragStart={isDesignMode ? handleDragStart : undefined}
|
|
onDragEnd={isDesignMode ? handleDragEnd : undefined}
|
|
>
|
|
{/* 동적 컴포넌트 렌더링 */}
|
|
<div
|
|
ref={(node) => {
|
|
// 멀티 ref 처리
|
|
innerDivRef.current = node;
|
|
if (component.type === "component" && (component as any).componentType === "flow-widget") {
|
|
(contentRef as any).current = node;
|
|
}
|
|
}}
|
|
className={`${
|
|
(component.type === "component" && (component as any).componentType === "flow-widget") ||
|
|
((component as any).componentType === "conditional-container" && !isDesignMode)
|
|
? "h-auto"
|
|
: "h-full"
|
|
} overflow-visible`}
|
|
style={{ width: "100%", maxWidth: "100%" }}
|
|
>
|
|
<DynamicComponentRenderer
|
|
component={enhancedComponent}
|
|
isSelected={isSelected}
|
|
isDesignMode={isDesignMode}
|
|
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브
|
|
onClick={onClick}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
children={children}
|
|
selectedScreen={selectedScreen}
|
|
onZoneComponentDrop={onZoneComponentDrop}
|
|
onZoneClick={onZoneClick}
|
|
onConfigChange={onConfigChange}
|
|
screenId={screenId}
|
|
tableName={tableName}
|
|
userId={userId}
|
|
userName={userName}
|
|
companyCode={companyCode}
|
|
menuObjid={menuObjid}
|
|
selectedRowsData={selectedRowsData}
|
|
onSelectedRowsChange={onSelectedRowsChange}
|
|
flowSelectedData={flowSelectedData}
|
|
flowSelectedStepId={flowSelectedStepId}
|
|
onFlowSelectedDataChange={onFlowSelectedDataChange}
|
|
refreshKey={refreshKey}
|
|
onRefresh={onRefresh}
|
|
flowRefreshKey={flowRefreshKey}
|
|
onFlowRefresh={onFlowRefresh}
|
|
formData={formData}
|
|
onFormDataChange={onFormDataChange}
|
|
sortBy={sortBy}
|
|
sortOrder={sortOrder}
|
|
columnOrder={columnOrder}
|
|
onHeightChange={onHeightChange}
|
|
/>
|
|
</div>
|
|
|
|
{/* 선택된 컴포넌트 정보 표시 */}
|
|
{isSelected && (
|
|
<div className="bg-primary text-primary-foreground absolute -top-7 left-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
|
|
{type === "widget" && (
|
|
<div className="flex items-center gap-1.5">
|
|
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
|
<span>{(component as WidgetComponent).widgetType || "widget"}</span>
|
|
</div>
|
|
)}
|
|
{type !== "widget" && (
|
|
<div className="flex items-center gap-1.5">
|
|
<span>{component.componentConfig?.type || type}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 기존 RealtimePreview와의 호환성을 위한 export
|
|
export { RealtimePreviewDynamic as RealtimePreview };
|
|
export default RealtimePreviewDynamic;
|