"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 { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; // 컴포넌트 렌더러들 자동 등록 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; // 설정 변경 핸들러 onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백 onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백 selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID onResize?: (componentId: string, newSize: { width: number; height: number }) => 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; onFormDataChange?: (fieldName: string, value: any) => void; // 테이블 정렬 정보 sortBy?: string; sortOrder?: "asc" | "desc"; columnOrder?: string[]; // 🆕 조건부 컨테이너 높이 변화 콜백 onHeightChange?: (componentId: string, newHeight: number) => void; // 🆕 조건부 비활성화 상태 conditionalDisabled?: boolean; } // 동적 위젯 타입 아이콘 (레지스트리에서 조회) const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => { if (!widgetType) return ; const iconMap: Record = { text: Aa, number: , decimal: , date: , datetime: , select: , dropdown: , textarea: , boolean: , checkbox: , radio: , code: , entity: , file: , email: @, tel: , button: BTN, }; return iconMap[widgetType] || ; }; const RealtimePreviewDynamicComponent: React.FC = ({ 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, // 🆕 조건부 컨테이너 높이 변화 콜백 conditionalDisabled, // 🆕 조건부 비활성화 상태 onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백 onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백 selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID onResize, // 🆕 리사이즈 콜백 }) => { // 🆕 화면 다국어 컨텍스트 const { getTranslatedText } = useScreenMultiLang(); const [actualHeight, setActualHeight] = React.useState(null); const contentRef = React.useRef(null); const lastUpdatedHeight = React.useRef(null); // 🆕 리사이즈 상태 const [isResizing, setIsResizing] = React.useState(false); const [resizeSize, setResizeSize] = React.useState<{ width: number; height: number } | null>(null); const rafRef = React.useRef(null); // 🆕 size가 업데이트되면 resizeSize 초기화 (레이아웃 상태가 props에 반영되었음) React.useEffect(() => { if (resizeSize && !isResizing) { // component.size가 resizeSize와 같아지면 resizeSize 초기화 if (component.size?.width === resizeSize.width && component.size?.height === resizeSize.height) { setResizeSize(null); } } }, [component.size?.width, component.size?.height, resizeSize, isResizing]); // 10px 단위 스냅 함수 const snapTo10 = (value: number) => Math.round(value / 10) * 10; // 🆕 리사이즈 핸들러 const handleResizeStart = React.useCallback( (e: React.MouseEvent, direction: "e" | "s" | "se") => { e.stopPropagation(); e.preventDefault(); const startMouseX = e.clientX; const startMouseY = e.clientY; const startWidth = component.size?.width || 200; const startHeight = component.size?.height || 100; setIsResizing(true); setResizeSize({ width: startWidth, height: startHeight }); const handleMouseMove = (moveEvent: MouseEvent) => { if (rafRef.current) { cancelAnimationFrame(rafRef.current); } rafRef.current = requestAnimationFrame(() => { const deltaX = moveEvent.clientX - startMouseX; const deltaY = moveEvent.clientY - startMouseY; let newWidth = startWidth; let newHeight = startHeight; if (direction === "e" || direction === "se") { newWidth = snapTo10(Math.max(50, startWidth + deltaX)); } if (direction === "s" || direction === "se") { newHeight = snapTo10(Math.max(20, startHeight + deltaY)); } setResizeSize({ width: newWidth, height: newHeight }); }); }; const handleMouseUp = (upEvent: MouseEvent) => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } const deltaX = upEvent.clientX - startMouseX; const deltaY = upEvent.clientY - startMouseY; let newWidth = startWidth; let newHeight = startHeight; if (direction === "e" || direction === "se") { newWidth = snapTo10(Math.max(50, startWidth + deltaX)); } if (direction === "s" || direction === "se") { newHeight = snapTo10(Math.max(20, startHeight + deltaY)); } // 🆕 리사이즈 상태는 유지한 채로 크기 변경 콜백 호출 // resizeSize는 null로 설정하지 않고 마지막 크기 유지 // (component.size가 업데이트되면 자연스럽게 올바른 크기 표시) // 🆕 크기 변경 콜백 호출하여 레이아웃 상태 업데이트 if (onResize) { onResize(component.id, { width: newWidth, height: newHeight }); } // 🆕 리사이즈 플래그만 해제 (resizeSize는 마지막 크기 유지) setIsResizing(false); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, [component.id, component.size, onResize] ); // 플로우 위젯의 실제 높이 측정 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순위: size.height가 있으면 우선 사용 (레이아웃에서 관리되는 실제 크기) // size는 레이아웃 상태에서 직접 관리되며 리사이즈로 변경됨 if (size?.height && size.height > 0) { if (component.componentConfig?.type === "table-list") { return `${Math.max(size.height, 200)}px`; } return `${size.height}px`; } // 2순위: componentStyle.height (컴포넌트 정의에서 온 기본 스타일) if (componentStyle?.height) { return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height; } // 3순위: 기본값 if (component.componentConfig?.type === "table-list") { return "200px"; } // 기본 높이 return "10px"; }; // 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(null); const initialPanelIdRef = React.useRef(null); // 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지) const isInLeftPanelRef = React.useRef(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(); // 🆕 리사이즈 크기가 있으면 우선 사용 // (size가 업데이트되면 위 useEffect에서 resizeSize를 null로 설정) const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth(); const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight(); const baseStyle = { left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용 top: `${position.y}px`, ...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨) width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용 height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용 zIndex: component.type === "layout" ? 1 : position.z || 2, right: undefined, // 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음 transition: isResizing ? "none" : 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(null); const outerDivRef = React.useRef(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 (
{/* 동적 컴포넌트 렌더링 */}
{ // 멀티 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%" }} >
{/* 선택된 컴포넌트 정보 표시 */} {isSelected && (
{type === "widget" && (
{getWidgetIcon((component as WidgetComponent).widgetType)} {(component as WidgetComponent).widgetType || "widget"}
)} {type !== "widget" && (
{component.componentConfig?.type || type}
)}
)} {/* 🆕 리사이즈 가장자리 영역 - 선택된 컴포넌트 + 디자인 모드에서만 표시 */} {isSelected && isDesignMode && onResize && ( <> {/* 오른쪽 가장자리 (너비 조절) */}
handleResizeStart(e, "e")} /> {/* 아래 가장자리 (높이 조절) */}
handleResizeStart(e, "s")} /> {/* 오른쪽 아래 모서리 (너비+높이 조절) */}
handleResizeStart(e, "se")} /> )}
); }; // React.memo로 래핑하여 불필요한 리렌더링 방지 export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent); // displayName 설정 (디버깅용) RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic"; // 기존 RealtimePreview와의 호환성을 위한 export export { RealtimePreviewDynamic as RealtimePreview }; export default RealtimePreviewDynamic;