Files
vexplor/frontend/components/screen/RealtimePreviewDynamic.tsx
DDD1542 5afa373b1f refactor: Update middleware and enhance component interactions
- Improved the middleware to handle authentication checks more effectively, ensuring that users are redirected appropriately based on their authentication status.
- Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to utilize a new subscription method for DOM manipulation during drag events, enhancing performance and user experience.
- Refactored the SplitLineComponent to optimize drag handling and state management, ensuring smoother interactions during component adjustments.
- Integrated API client for menu data loading, streamlining token management and error handling.
2026-02-24 11:02:43 +09:00

780 lines
30 KiB
TypeScript

"use client";
import React, { useMemo, useSyncExternalStore } 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 {
subscribe as canvasSplitSubscribe,
getSnapshot as canvasSplitGetSnapshot,
getServerSnapshot as canvasSplitGetServerSnapshot,
subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
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
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId?: 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<string, any>;
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 <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" />;
};
const RealtimePreviewDynamicComponent: 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, // 🆕 조건부 컨테이너 높이 변화 콜백
conditionalDisabled, // 🆕 조건부 비활성화 상태
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
onResize, // 🆕 리사이즈 콜백
}) => {
// 🆕 화면 다국어 컨텍스트
const { getTranslatedText } = useScreenMultiLang();
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const lastUpdatedHeight = React.useRef<number | null>(null);
// 🆕 리사이즈 상태
const [isResizing, setIsResizing] = React.useState(false);
const [resizeSize, setResizeSize] = React.useState<{ width: number; height: number } | null>(null);
const rafRef = React.useRef<number | null>(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 (레거시 split-panel-layout용)
const splitPanelContext = useSplitPanel();
// 캔버스 분할선 글로벌 스토어 (useSyncExternalStore로 직접 구독)
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
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)));
// 레거시 분할 패널용 refs
const initialPanelRatioRef = React.useRef<number | null>(null);
const initialPanelIdRef = React.useRef<string | null>(null);
const isInLeftPanelRef = React.useRef<boolean | null>(null);
// 캔버스 분할선 좌/우 판정 (한 번만)
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
// 스코프 체크 캐시 (DOM 쿼리 최소화)
const myScopeIdRef = React.useRef<string | null>(null);
const calculateSplitAdjustedPosition = () => {
const isSplitLineComponent =
type === "component" && componentType === "v2-split-line";
if (isSplitLineComponent) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
// === 1. 캔버스 분할선 (글로벌 스토어) ===
if (canvasSplit.active && canvasSplit.canvasWidth > 0 && canvasSplit.scopeId) {
if (myScopeIdRef.current === null) {
const el = document.getElementById(`component-${id}`);
const container = el?.closest("[data-screen-runtime]");
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
}
if (myScopeIdRef.current !== canvasSplit.scopeId) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
const { initialDividerX, currentDividerX, canvasWidth, isDragging: splitDragging } = canvasSplit;
const delta = currentDividerX - initialDividerX;
if (canvasSplitSideRef.current === null) {
const origW = size?.width || 100;
const componentCenterX = position.x + (origW / 2);
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
}
if (Math.abs(delta) < 1) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
}
// 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음
const origW = size?.width || 100;
const GAP = 4;
let adjustedX: number;
let adjustedW: number;
if (canvasSplitSideRef.current === "left") {
const initialZoneWidth = initialDividerX;
const currentZoneWidth = Math.max(20, currentDividerX - GAP);
const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1;
adjustedX = position.x * scale;
adjustedW = origW * scale;
if (adjustedX + adjustedW > currentDividerX - GAP) {
adjustedW = currentDividerX - GAP - adjustedX;
}
} else {
const initialRightWidth = canvasWidth - initialDividerX;
const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
const rightOffset = position.x - initialDividerX;
adjustedX = currentDividerX + GAP + rightOffset * scale;
adjustedW = origW * scale;
if (adjustedX < currentDividerX + GAP) adjustedX = currentDividerX + GAP;
if (adjustedX + adjustedW > canvasWidth) adjustedW = canvasWidth - adjustedX;
}
adjustedX = Math.max(0, adjustedX);
adjustedW = Math.max(20, adjustedW);
return { adjustedPositionX: adjustedX, adjustedWidth: adjustedW, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
}
// === 2. 레거시 분할 패널 (Context) - 버튼 전용 ===
const isSplitPanelComponent =
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
if (isSplitPanelComponent) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
if (!isButtonComponent) {
return { adjustedPositionX: position.x, adjustedWidth: null, 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, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
const { panel } = overlap;
if (initialPanelIdRef.current !== overlap.panelId) {
initialPanelRatioRef.current = panel.initialLeftWidthPercent;
initialPanelIdRef.current = overlap.panelId;
const initialDividerX = panel.x + (panel.width * panel.initialLeftWidthPercent) / 100;
const componentCenterX = position.x + componentWidth / 2;
isInLeftPanelRef.current = componentCenterX < initialDividerX;
}
const baseRatio = initialPanelRatioRef.current ?? panel.initialLeftWidthPercent;
const initialDividerX = panel.x + (panel.width * baseRatio) / 100;
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100;
const dividerDelta = currentDividerX - initialDividerX;
if (Math.abs(dividerDelta) < 1) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging };
}
const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x;
return {
adjustedPositionX: adjustedX,
adjustedWidth: null,
isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging,
};
};
const { adjustedPositionX, adjustedWidth: splitAdjustedWidth, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition();
const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
const origWidth = size?.width || 100;
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
const baseStyle = {
left: `${adjustedPositionX}px`,
top: `${position.y}px`,
...componentStyle,
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
height: displayHeight,
zIndex: component.type === "layout" ? 1 : position.z || 2,
right: undefined,
overflow: isSplitShrunk ? "hidden" as const : undefined,
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
transition:
isResizing ? "none" :
isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out, width 0.15s 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?.();
};
const splitAdjustedComp = React.useMemo(() => {
if (isSplitShrunk && splitAdjustedWidth !== null) {
return { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: Math.round(splitAdjustedWidth) } };
}
return enhancedComponent;
}, [enhancedComponent, isSplitShrunk, splitAdjustedWidth]);
// 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트)
React.useEffect(() => {
const isSplitLine = type === "component" && componentType === "v2-split-line";
if (isSplitLine) return;
const unsubscribe = canvasSplitSubscribeDom((snap) => {
if (!snap.isDragging || !snap.active || !snap.scopeId) return;
if (myScopeIdRef.current !== snap.scopeId) return;
const el = outerDivRef.current;
if (!el) return;
const origX = position.x;
const oW = size?.width || 100;
const { initialDividerX, currentDividerX, canvasWidth } = snap;
const delta = currentDividerX - initialDividerX;
if (Math.abs(delta) < 1) return;
if (canvasSplitSideRef.current === null) {
canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
}
const GAP = 4;
let nx: number, nw: number;
if (canvasSplitSideRef.current === "left") {
const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1;
nx = origX * scale;
nw = oW * scale;
if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx;
} else {
const irw = canvasWidth - initialDividerX;
const crw = Math.max(20, canvasWidth - currentDividerX - GAP);
const scale = irw > 0 ? crw / irw : 1;
nx = currentDividerX + GAP + (origX - initialDividerX) * scale;
nw = oW * scale;
if (nx < currentDividerX + GAP) nx = currentDividerX + GAP;
if (nx + nw > canvasWidth) nw = canvasWidth - nx;
}
nx = Math.max(0, nx);
nw = Math.max(20, nw);
el.style.left = `${nx}px`;
el.style.width = `${Math.round(nw)}px`;
el.style.overflow = nw < oW ? "hidden" : "";
});
return unsubscribe;
}, [id, position.x, size?.width, type, componentType]);
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="h-full overflow-visible"
style={{ width: "100%", maxWidth: "100%" }}
>
<DynamicComponentRenderer
component={splitAdjustedComp}
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}
conditionalDisabled={conditionalDisabled}
onUpdateComponent={onUpdateComponent}
onSelectTabComponent={onSelectTabComponent}
selectedTabComponentId={selectedTabComponentId}
onSelectPanelComponent={onSelectPanelComponent}
selectedPanelComponentId={selectedPanelComponentId}
/>
</div>
{/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */}
{isSelected && (
<div className="bg-primary text-primary-foreground absolute -top-7 right-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>
)}
{/* 🆕 리사이즈 가장자리 영역 - 선택된 컴포넌트 + 디자인 모드에서만 표시 */}
{isSelected && isDesignMode && onResize && (
<>
{/* 오른쪽 가장자리 (너비 조절) */}
<div
className="absolute top-0 right-0 w-2 h-full cursor-ew-resize z-20 hover:bg-primary/10"
onMouseDown={(e) => handleResizeStart(e, "e")}
/>
{/* 아래 가장자리 (높이 조절) */}
<div
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize z-20 hover:bg-primary/10"
onMouseDown={(e) => handleResizeStart(e, "s")}
/>
{/* 오른쪽 아래 모서리 (너비+높이 조절) */}
<div
className="absolute bottom-0 right-0 w-3 h-3 cursor-nwse-resize z-30 hover:bg-primary/20"
onMouseDown={(e) => handleResizeStart(e, "se")}
/>
</>
)}
</div>
);
};
// 🔧 arePropsEqual 제거 - 기본 React.memo 사용 (디버깅용)
// component 객체가 새로 생성되면 자동으로 리렌더링됨
export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent);
// displayName 설정 (디버깅용)
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";
// 기존 RealtimePreview와의 호환성을 위한 export
export { RealtimePreviewDynamic as RealtimePreview };
export default RealtimePreviewDynamic;