Merge pull request '분할패널 버튼 이동 가능하게 수정' (#281) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/281
This commit is contained in:
kjs 2025-12-11 18:40:57 +09:00
commit e8af7ae4c6
8 changed files with 1021 additions and 162 deletions

View File

@ -21,6 +21,7 @@ import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈
function ScreenViewPage() {
const params = useParams();
@ -307,10 +308,7 @@ function ScreenViewPage() {
return (
<ScreenPreviewProvider isPreviewMode={false}>
<TableOptionsProvider>
<div
ref={containerRef}
className="bg-background h-full w-full overflow-auto p-3"
>
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
@ -358,7 +356,6 @@ function ScreenViewPage() {
return isButton;
});
topLevelComponents.forEach((component) => {
const isButton =
(component.type === "component" &&
@ -799,7 +796,9 @@ function ScreenViewPageWrapper() {
return (
<TableSearchWidgetHeightProvider>
<ScreenContextProvider>
<ScreenViewPage />
<SplitPanelProvider>
<ScreenViewPage />
</SplitPanelProvider>
</ScreenContextProvider>
</TableSearchWidgetHeightProvider>
);

View File

@ -50,6 +50,7 @@ import { cn } from "@/lib/utils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
/**
* 🔗
@ -2101,113 +2102,115 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
: component;
return (
<TableOptionsProvider>
<div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
{/* 메인 컨텐츠 */}
<div className="h-full flex-1" style={{ width: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
</div>
</div>
{/* 개선된 검증 패널 (선택적 표시) */}
{showValidationPanel && enhancedValidation && (
<div className="absolute bottom-4 right-4 z-50">
<FormValidationIndicator
validationState={enhancedValidation.validationState}
saveState={enhancedValidation.saveState}
onSave={async () => {
const success = await enhancedValidation.saveForm();
if (success) {
toast.success("데이터가 성공적으로 저장되었습니다!");
}
}}
canSave={enhancedValidation.canSave}
compact={true}
showDetails={false}
/>
</div>
)}
{/* 모달 화면 */}
<Dialog open={!!popupScreen} onOpenChange={() => {
setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
<DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>
<SplitPanelProvider>
<TableOptionsProvider>
<div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
{popupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> ...</div>
</div>
) : popupLayout.length > 0 ? (
<div className="relative bg-background border rounded" style={{
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
minHeight: "400px",
position: "relative",
overflow: "hidden"
}}>
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
{popupLayout.map((popupComponent) => (
<div
key={popupComponent.id}
className="absolute"
style={{
left: `${popupComponent.position.x}px`,
top: `${popupComponent.position.y}px`,
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
}}
>
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
<InteractiveScreenViewer
component={popupComponent}
allComponents={popupLayout}
hideLabel={false}
screenInfo={popupScreenInfo || undefined}
formData={popupFormData}
onFormDataChange={(fieldName, value) => {
console.log("💾 팝업 formData 업데이트:", {
fieldName,
value,
valueType: typeof value,
prevFormData: popupFormData
});
setPopupFormData(prev => ({
...prev,
[fieldName]: value
}));
}}
/>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> .</div>
</div>
{/* 메인 컨텐츠 */}
<div className="h-full flex-1" style={{ width: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
</div>
</DialogContent>
</Dialog>
</TableOptionsProvider>
</div>
{/* 개선된 검증 패널 (선택적 표시) */}
{showValidationPanel && enhancedValidation && (
<div className="absolute bottom-4 right-4 z-50">
<FormValidationIndicator
validationState={enhancedValidation.validationState}
saveState={enhancedValidation.saveState}
onSave={async () => {
const success = await enhancedValidation.saveForm();
if (success) {
toast.success("데이터가 성공적으로 저장되었습니다!");
}
}}
canSave={enhancedValidation.canSave}
compact={true}
showDetails={false}
/>
</div>
)}
{/* 모달 화면 */}
<Dialog open={!!popupScreen} onOpenChange={() => {
setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
<DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
{popupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> ...</div>
</div>
) : popupLayout.length > 0 ? (
<div className="relative bg-background border rounded" style={{
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
minHeight: "400px",
position: "relative",
overflow: "hidden"
}}>
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
{popupLayout.map((popupComponent) => (
<div
key={popupComponent.id}
className="absolute"
style={{
left: `${popupComponent.position.x}px`,
top: `${popupComponent.position.y}px`,
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
}}
>
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
<InteractiveScreenViewer
component={popupComponent}
allComponents={popupLayout}
hideLabel={false}
screenInfo={popupScreenInfo || undefined}
formData={popupFormData}
onFormDataChange={(fieldName, value) => {
console.log("💾 팝업 formData 업데이트:", {
fieldName,
value,
valueType: typeof value,
prevFormData: popupFormData
});
setPopupFormData(prev => ({
...prev,
[fieldName]: value
}));
}}
/>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> .</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</TableOptionsProvider>
</SplitPanelProvider>
);
};

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
import { Input } from "@/components/ui/input";
@ -14,6 +14,7 @@ import { FileUpload } from "./widgets/FileUpload";
import { useAuth } from "@/hooks/useAuth";
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import {
Database,
Type,
@ -110,8 +111,8 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
};
// 동적 웹 타입 위젯 렌더링 컴포넌트
const WidgetRenderer: React.FC<{
component: ComponentData;
const WidgetRenderer: React.FC<{
component: ComponentData;
isDesignMode?: boolean;
sortBy?: string;
sortOrder?: "asc" | "desc";
@ -253,22 +254,23 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 플로우 위젯의 실제 높이 측정
useEffect(() => {
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
const isFlowWidget =
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "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);
}
@ -400,12 +402,118 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}, [component.id, fileUpdateTrigger]);
// 컴포넌트 스타일 계산
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
const isFlowWidget =
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
const positionX = position?.x || 0;
const positionY = position?.y || 0;
// 🆕 분할 패널 리사이즈 Context
const { getAdjustedX, getOverlappingSplitPanel } = 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)));
// 디버깅: 모든 컴포넌트의 타입 정보 출력 (버튼 관련만)
if (componentType.includes("button") || componentId.includes("button") || widgetType.includes("button")) {
console.log("🔘 [RealtimePreview] 버튼 컴포넌트 발견:", {
id: component.id,
type,
componentType,
componentId,
widgetType,
isButtonComponent,
positionX,
positionY,
});
}
// 🆕 분할 패널 위 버튼 위치 자동 조정
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = useMemo(() => {
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
const isSplitPanelComponent =
type === "component" &&
["split-panel-layout", "split-panel-layout2"].includes((component as any).componentType || "");
if (!isButtonComponent || isSplitPanelComponent) {
return { adjustedPositionX: positionX, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
const componentWidth = size?.width || 100;
const componentHeight = size?.height || 40;
// 분할 패널 위에 있는지 확인
const overlap = getOverlappingSplitPanel(positionX, positionY, componentWidth, componentHeight);
// 디버깅: 버튼이 분할 패널 위에 있는지 확인
if (isButtonComponent) {
console.log("🔍 [RealtimePreview] 버튼 분할 패널 감지:", {
componentId: component.id,
componentType: (component as any).componentType,
positionX,
positionY,
componentWidth,
componentHeight,
hasOverlap: !!overlap,
isInLeftPanel: overlap?.isInLeftPanel,
panelInfo: overlap
? {
panelId: overlap.panelId,
panelX: overlap.panel.x,
panelY: overlap.panel.y,
panelWidth: overlap.panel.width,
leftWidthPercent: overlap.panel.leftWidthPercent,
initialLeftWidthPercent: overlap.panel.initialLeftWidthPercent,
}
: null,
});
}
if (!overlap || !overlap.isInLeftPanel) {
// 분할 패널 위에 없거나 우측 패널 위에 있음
return {
adjustedPositionX: positionX,
isOnSplitPanel: !!overlap,
isDraggingSplitPanel: overlap?.panel.isDragging ?? false,
};
}
// 좌측 패널 위에 있음 - 위치 조정
const adjusted = getAdjustedX(positionX, positionY, componentWidth, componentHeight);
console.log("✅ [RealtimePreview] 버튼 위치 조정 적용:", {
componentId: component.id,
originalX: positionX,
adjustedX: adjusted,
delta: adjusted - positionX,
});
return {
adjustedPositionX: adjusted,
isOnSplitPanel: true,
isDraggingSplitPanel: overlap.panel.isDragging,
};
}, [
positionX,
positionY,
size?.width,
size?.height,
isButtonComponent,
type,
component,
getAdjustedX,
getOverlappingSplitPanel,
]);
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
const getWidth = () => {
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
@ -437,23 +545,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
const componentStyle = {
position: "absolute" as const,
...style, // 먼저 적용하고
left: positionX,
left: adjustedPositionX, // 🆕 분할 패널 위 버튼은 조정된 X 좌표 사용
top: positionY,
width: getWidth(), // 우선순위에 따른 너비
height: getHeight(), // 우선순위에 따른 높이
zIndex: position?.z || 1,
// right 속성 강제 제거
right: undefined,
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
transition:
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
};
// 선택된 컴포넌트 스타일
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
const selectionStyle = isSelected && !isSectionPaper
? {
outline: "2px solid rgb(59, 130, 246)",
outlineOffset: "2px",
}
: {};
const selectionStyle =
isSelected && !isSectionPaper
? {
outline: "2px solid rgb(59, 130, 246)",
outlineOffset: "2px",
}
: {};
const handleClick = (e: React.MouseEvent) => {
// 컴포넌트 영역 내에서만 클릭 이벤트 처리
@ -481,10 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onDragEnd={handleDragEnd}
>
{/* 컴포넌트 타입별 렌더링 */}
<div
ref={isFlowWidget ? contentRef : undefined}
className="h-full w-full"
>
<div ref={isFlowWidget ? contentRef : undefined} className="h-full w-full">
{/* 영역 타입 */}
{type === "area" && renderArea(component, children)}
@ -549,16 +658,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return (
<div className="h-auto w-full">
<FlowWidget
component={flowComponent as any}
onSelectedDataChange={onFlowSelectedDataChange}
/>
<FlowWidget component={flowComponent as any} onSelectedDataChange={onFlowSelectedDataChange} />
</div>
);
})()}
{/* 탭 컴포넌트 타입 */}
{(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) &&
{(type === "tabs" ||
(type === "component" &&
((component as any).componentType === "tabs-widget" ||
(component as any).componentId === "tabs-widget"))) &&
(() => {
console.log("🎯 탭 컴포넌트 조건 충족:", {
type,
@ -590,9 +699,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
<Badge key={tab.id} variant="outline" className="text-xs">
{tab.label || `${index + 1}`}
{tab.screenName && (
<span className="ml-1 text-[10px] text-gray-400">
({tab.screenName})
</span>
<span className="ml-1 text-[10px] text-gray-400">({tab.screenName})</span>
)}
</Badge>
))}
@ -632,28 +739,29 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
)}
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
{type === "component" && (() => {
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
return (
<DynamicComponentRenderer
component={component}
isSelected={isSelected}
isDesignMode={isDesignMode}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...restProps}
>
{children}
</DynamicComponentRenderer>
);
})()}
{type === "component" &&
(() => {
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
return (
<DynamicComponentRenderer
component={component}
isSelected={isSelected}
isDesignMode={isDesignMode}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...restProps}
>
{children}
</DynamicComponentRenderer>
);
})()}
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
{type === "widget" && !isFileComponent(component) && (
<div className="h-full w-full">
<WidgetRenderer
component={component}
<WidgetRenderer
component={component}
isDesignMode={isDesignMode}
sortBy={sortBy}
sortOrder={sortOrder}

View File

@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useMemo } from "react";
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import {
@ -16,6 +16,7 @@ import {
Building,
File,
} from "lucide-react";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
// 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components";
@ -60,7 +61,7 @@ interface RealtimePreviewProps {
sortBy?: string;
sortOrder?: "asc" | "desc";
columnOrder?: string[];
// 🆕 조건부 컨테이너 높이 변화 콜백
onHeightChange?: (componentId: string, newHeight: number) => void;
}
@ -262,14 +263,145 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}
: 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: `${position.x}px`,
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,
};
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)

View File

@ -0,0 +1,92 @@
"use client";
import React, { useMemo } from "react";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
interface SplitPanelAwareWrapperProps {
children: React.ReactNode;
componentX: number;
componentY: number;
componentWidth: number;
componentHeight: number;
componentType?: string;
style?: React.CSSProperties;
className?: string;
}
/**
*
*
* :
* 1.
* 2. , X
* 3.
*/
export const SplitPanelAwareWrapper: React.FC<SplitPanelAwareWrapperProps> = ({
children,
componentX,
componentY,
componentWidth,
componentHeight,
componentType,
style,
className,
}) => {
const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel();
// 분할 패널 위에 있는지 확인 및 조정된 X 좌표 계산
const { adjustedX, isInLeftPanel, isDragging } = useMemo(() => {
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
if (!overlap) {
// 분할 패널 위에 없음
return { adjustedX: componentX, isInLeftPanel: false, isDragging: false };
}
if (!overlap.isInLeftPanel) {
// 우측 패널 위에 있음 - 원래 위치 유지
return { adjustedX: componentX, isInLeftPanel: false, isDragging: overlap.panel.isDragging };
}
// 좌측 패널 위에 있음 - 위치 조정
const adjusted = getAdjustedX(componentX, componentY, componentWidth, componentHeight);
return {
adjustedX: adjusted,
isInLeftPanel: true,
isDragging: overlap.panel.isDragging,
};
}, [componentX, componentY, componentWidth, componentHeight, getAdjustedX, getOverlappingSplitPanel]);
// 조정된 스타일
const adjustedStyle: React.CSSProperties = {
...style,
position: "absolute",
left: `${adjustedX}px`,
top: `${componentY}px`,
width: componentWidth,
height: componentHeight,
// 드래그 중에는 트랜지션 없이 즉시 이동, 드래그 끝나면 부드럽게
transition: isDragging ? "none" : "left 0.1s ease-out",
};
// 디버그 로깅 (개발 중에만)
// if (isInLeftPanel) {
// console.log(`📍 [SplitPanelAwareWrapper] 위치 조정:`, {
// componentType,
// originalX: componentX,
// adjustedX,
// delta: adjustedX - componentX,
// isInLeftPanel,
// isDragging,
// });
// }
return (
<div style={adjustedStyle} className={className}>
{children}
</div>
);
};
export default SplitPanelAwareWrapper;

View File

@ -0,0 +1,400 @@
"use client";
import React, { createContext, useContext, useState, useCallback, useRef, useMemo } from "react";
/**
* SplitPanelResize Context
* ( ) Context
*
* 주의: contexts/SplitPanelContext.tsx는 Context이고,
* Context는 Context입니다.
*/
/**
* ( )
*/
export interface SplitPanelInfo {
id: string;
// 분할 패널의 좌표 (스크린 캔버스 기준, px)
x: number;
y: number;
width: number;
height: number;
// 좌측 패널 비율 (0-100)
leftWidthPercent: number;
// 초기 좌측 패널 비율 (드래그 시작 시점)
initialLeftWidthPercent: number;
// 드래그 중 여부
isDragging: boolean;
}
export interface SplitPanelResizeContextValue {
// 등록된 분할 패널들
splitPanels: Map<string, SplitPanelInfo>;
// 분할 패널 등록/해제/업데이트
registerSplitPanel: (id: string, info: Omit<SplitPanelInfo, "id">) => void;
unregisterSplitPanel: (id: string) => void;
updateSplitPanel: (id: string, updates: Partial<SplitPanelInfo>) => void;
// 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
// 반환값: { panelId, offsetX } 또는 null
getOverlappingSplitPanel: (
componentX: number,
componentY: number,
componentWidth: number,
componentHeight: number,
) => { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null;
// 컴포넌트의 조정된 X 좌표 계산
// 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
getAdjustedX: (componentX: number, componentY: number, componentWidth: number, componentHeight: number) => number;
// 레거시 호환 (단일 분할 패널용)
leftWidthPercent: number;
containerRect: DOMRect | null;
dividerX: number;
isDragging: boolean;
splitPanelId: string | null;
updateLeftWidth: (percent: number) => void;
updateContainerRect: (rect: DOMRect | null) => void;
updateDragging: (dragging: boolean) => void;
}
// Context 생성
const SplitPanelResizeContext = createContext<SplitPanelResizeContextValue | null>(null);
/**
* SplitPanelResize Context Provider
*
*/
export const SplitPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// 등록된 분할 패널들
const splitPanelsRef = useRef<Map<string, SplitPanelInfo>>(new Map());
const [, forceUpdate] = useState(0);
// 레거시 호환용 상태
const [legacyLeftWidthPercent, setLegacyLeftWidthPercent] = useState(30);
const [legacyContainerRect, setLegacyContainerRect] = useState<DOMRect | null>(null);
const [legacyIsDragging, setLegacyIsDragging] = useState(false);
const [legacySplitPanelId, setLegacySplitPanelId] = useState<string | null>(null);
// 분할 패널 등록
const registerSplitPanel = useCallback((id: string, info: Omit<SplitPanelInfo, "id">) => {
splitPanelsRef.current.set(id, { id, ...info });
setLegacySplitPanelId(id);
setLegacyLeftWidthPercent(info.leftWidthPercent);
forceUpdate((n) => n + 1);
}, []);
// 분할 패널 해제
const unregisterSplitPanel = useCallback(
(id: string) => {
splitPanelsRef.current.delete(id);
if (legacySplitPanelId === id) {
setLegacySplitPanelId(null);
}
forceUpdate((n) => n + 1);
},
[legacySplitPanelId],
);
// 분할 패널 업데이트
const updateSplitPanel = useCallback((id: string, updates: Partial<SplitPanelInfo>) => {
const panel = splitPanelsRef.current.get(id);
if (panel) {
const updatedPanel = { ...panel, ...updates };
splitPanelsRef.current.set(id, updatedPanel);
// 레거시 호환 상태 업데이트
if (updates.leftWidthPercent !== undefined) {
setLegacyLeftWidthPercent(updates.leftWidthPercent);
}
if (updates.isDragging !== undefined) {
setLegacyIsDragging(updates.isDragging);
}
forceUpdate((n) => n + 1);
}
}, []);
/**
*
*/
const getOverlappingSplitPanel = useCallback(
(
componentX: number,
componentY: number,
componentWidth: number,
componentHeight: number,
): { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null => {
for (const [panelId, panel] of splitPanelsRef.current) {
// 컴포넌트의 중심점
const componentCenterX = componentX + componentWidth / 2;
const componentCenterY = componentY + componentHeight / 2;
// 컴포넌트가 분할 패널 영역 내에 있는지 확인
const isInPanelX = componentCenterX >= panel.x && componentCenterX <= panel.x + panel.width;
const isInPanelY = componentCenterY >= panel.y && componentCenterY <= panel.y + panel.height;
if (isInPanelX && isInPanelY) {
// 좌측 패널의 현재 너비 (px)
const leftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
// 좌측 패널 경계 (분할 패널 기준 상대 좌표)
const dividerX = panel.x + leftPanelWidth;
// 컴포넌트 중심이 좌측 패널 내에 있는지 확인
const isInLeftPanel = componentCenterX < dividerX;
return { panelId, panel, isInLeftPanel };
}
}
return null;
},
[],
);
/**
* X
* , X
*
* :
* - X
* - , X
*/
const getAdjustedX = useCallback(
(componentX: number, componentY: number, componentWidth: number, componentHeight: number): number => {
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
if (!overlap || !overlap.isInLeftPanel) {
// 분할 패널 위에 없거나, 우측 패널 위에 있으면 원래 위치 유지
return componentX;
}
const { panel } = overlap;
// 초기 좌측 패널 너비 (설정된 splitRatio 기준)
const initialLeftPanelWidth = (panel.width * panel.initialLeftWidthPercent) / 100;
// 현재 좌측 패널 너비 (드래그로 변경된 값)
const currentLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
// 변화가 없으면 원래 위치 반환
if (Math.abs(initialLeftPanelWidth - currentLeftPanelWidth) < 1) {
return componentX;
}
// 컴포넌트의 분할 패널 내 상대 X 좌표
const relativeX = componentX - panel.x;
// 좌측 패널 내에서의 비율 (0~1)
const ratioInLeftPanel = relativeX / initialLeftPanelWidth;
// 조정된 상대 X 좌표 = 원래 비율 * 현재 좌측 패널 너비
const adjustedRelativeX = ratioInLeftPanel * currentLeftPanelWidth;
// 절대 X 좌표로 변환
const adjustedX = panel.x + adjustedRelativeX;
console.log("📍 [SplitPanel] 버튼 위치 조정:", {
componentX,
panelX: panel.x,
relativeX,
initialLeftPanelWidth,
currentLeftPanelWidth,
ratioInLeftPanel,
adjustedX,
delta: adjustedX - componentX,
});
return adjustedX;
},
[getOverlappingSplitPanel],
);
// 레거시 호환 - dividerX 계산
const legacyDividerX = legacyContainerRect ? (legacyContainerRect.width * legacyLeftWidthPercent) / 100 : 0;
// 레거시 호환 함수들
const updateLeftWidth = useCallback((percent: number) => {
setLegacyLeftWidthPercent(percent);
// 첫 번째 분할 패널 업데이트
const firstPanelId = splitPanelsRef.current.keys().next().value;
if (firstPanelId) {
const panel = splitPanelsRef.current.get(firstPanelId);
if (panel) {
splitPanelsRef.current.set(firstPanelId, { ...panel, leftWidthPercent: percent });
}
}
forceUpdate((n) => n + 1);
}, []);
const updateContainerRect = useCallback((rect: DOMRect | null) => {
setLegacyContainerRect(rect);
}, []);
const updateDragging = useCallback((dragging: boolean) => {
setLegacyIsDragging(dragging);
// 첫 번째 분할 패널 업데이트
const firstPanelId = splitPanelsRef.current.keys().next().value;
if (firstPanelId) {
const panel = splitPanelsRef.current.get(firstPanelId);
if (panel) {
// 드래그 시작 시 초기 비율 저장
const updates: Partial<SplitPanelInfo> = { isDragging: dragging };
if (dragging) {
updates.initialLeftWidthPercent = panel.leftWidthPercent;
}
splitPanelsRef.current.set(firstPanelId, { ...panel, ...updates });
}
}
forceUpdate((n) => n + 1);
}, []);
const value = useMemo<SplitPanelResizeContextValue>(
() => ({
splitPanels: splitPanelsRef.current,
registerSplitPanel,
unregisterSplitPanel,
updateSplitPanel,
getOverlappingSplitPanel,
getAdjustedX,
// 레거시 호환
leftWidthPercent: legacyLeftWidthPercent,
containerRect: legacyContainerRect,
dividerX: legacyDividerX,
isDragging: legacyIsDragging,
splitPanelId: legacySplitPanelId,
updateLeftWidth,
updateContainerRect,
updateDragging,
}),
[
registerSplitPanel,
unregisterSplitPanel,
updateSplitPanel,
getOverlappingSplitPanel,
getAdjustedX,
legacyLeftWidthPercent,
legacyContainerRect,
legacyDividerX,
legacyIsDragging,
legacySplitPanelId,
updateLeftWidth,
updateContainerRect,
updateDragging,
],
);
return <SplitPanelResizeContext.Provider value={value}>{children}</SplitPanelResizeContext.Provider>;
};
/**
* SplitPanelResize Context
* .
*/
export const useSplitPanel = (): SplitPanelResizeContextValue => {
const context = useContext(SplitPanelResizeContext);
// Context가 없으면 기본값 반환 (Provider 외부에서 사용 시)
if (!context) {
return {
splitPanels: new Map(),
registerSplitPanel: () => {},
unregisterSplitPanel: () => {},
updateSplitPanel: () => {},
getOverlappingSplitPanel: () => null,
getAdjustedX: (x) => x,
leftWidthPercent: 30,
containerRect: null,
dividerX: 0,
isDragging: false,
splitPanelId: null,
updateLeftWidth: () => {},
updateContainerRect: () => {},
updateDragging: () => {},
};
}
return context;
};
/**
*
* , X
*
* @param componentX - X (px)
* @param componentY - Y (px)
* @param componentWidth - (px)
* @param componentHeight - (px)
* @returns X
*/
export const useAdjustedComponentPosition = (
componentX: number,
componentY: number,
componentWidth: number,
componentHeight: number,
) => {
const context = useSplitPanel();
const adjustedX = context.getAdjustedX(componentX, componentY, componentWidth, componentHeight);
const overlap = context.getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
return {
adjustedX,
isInSplitPanel: !!overlap,
isInLeftPanel: overlap?.isInLeftPanel ?? false,
isDragging: overlap?.panel.isDragging ?? false,
panelId: overlap?.panelId ?? null,
};
};
/**
* ( )
*/
export const useAdjustedPosition = (originalXPercent: number) => {
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
const isInLeftPanel = originalXPercent <= leftWidthPercent;
const adjustedXPercent = isInLeftPanel ? (originalXPercent / 100) * leftWidthPercent : originalXPercent;
const adjustedXPx = containerRect ? (containerRect.width * adjustedXPercent) / 100 : 0;
return {
adjustedXPercent,
adjustedXPx,
isInLeftPanel,
isDragging,
dividerX,
containerRect,
leftWidthPercent,
};
};
/**
* , ( )
*/
export const useSplitPanelAwarePosition = (
initialLeftPercent: number,
options?: {
followDivider?: boolean;
offset?: number;
},
) => {
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
const { followDivider = false, offset = 0 } = options || {};
if (followDivider) {
return {
left: containerRect ? `${dividerX + offset}px` : `${leftWidthPercent}%`,
transition: isDragging ? "none" : "left 0.15s ease-out",
};
}
const adjustedLeft = (initialLeftPercent / 100) * leftWidthPercent;
return {
left: `${adjustedLeft}%`,
transition: isDragging ? "none" : "left 0.15s ease-out",
};
};
export default SplitPanelResizeContext;

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ComponentRendererProps } from "../../types";
import { SplitPanelLayoutConfig } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -36,6 +36,7 @@ import { Label } from "@/components/ui/label";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth";
import { useSplitPanel } from "./SplitPanelContext";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
@ -182,6 +183,120 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [leftWidth, setLeftWidth] = useState(splitRatio);
const containerRef = React.useRef<HTMLDivElement>(null);
// 🆕 SplitPanel Resize Context 연동 (버튼 등 외부 컴포넌트와 드래그 리사이즈 상태 공유)
const splitPanelContext = useSplitPanel();
const {
registerSplitPanel: ctxRegisterSplitPanel,
unregisterSplitPanel: ctxUnregisterSplitPanel,
updateSplitPanel: ctxUpdateSplitPanel,
} = splitPanelContext;
const splitPanelId = `split-panel-${component.id}`;
// 디버깅: Context 연결 상태 확인
console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
componentId: component.id,
splitPanelId,
hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
});
// Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행
const ctxRegisterRef = useRef(ctxRegisterSplitPanel);
const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel);
ctxRegisterRef.current = ctxRegisterSplitPanel;
ctxUnregisterRef.current = ctxUnregisterSplitPanel;
useEffect(() => {
// 컴포넌트의 위치와 크기 정보
const panelX = component.position?.x || 0;
const panelY = component.position?.y || 0;
const panelWidth = component.size?.width || component.style?.width || 800;
const panelHeight = component.size?.height || component.style?.height || 600;
const panelInfo = {
x: panelX,
y: panelY,
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
leftWidthPercent: splitRatio, // 초기값은 splitRatio 사용
initialLeftWidthPercent: splitRatio,
isDragging: false,
};
console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
splitPanelId,
panelInfo,
});
ctxRegisterRef.current(splitPanelId, panelInfo);
return () => {
console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
ctxUnregisterRef.current(splitPanelId);
};
// 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [splitPanelId]);
// 위치/크기 변경 시 Context 업데이트 (등록 후)
const ctxUpdateRef = useRef(ctxUpdateSplitPanel);
ctxUpdateRef.current = ctxUpdateSplitPanel;
useEffect(() => {
const panelX = component.position?.x || 0;
const panelY = component.position?.y || 0;
const panelWidth = component.size?.width || component.style?.width || 800;
const panelHeight = component.size?.height || component.style?.height || 600;
ctxUpdateRef.current(splitPanelId, {
x: panelX,
y: panelY,
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
});
}, [
splitPanelId,
component.position?.x,
component.position?.y,
component.size?.width,
component.size?.height,
component.style?.width,
component.style?.height,
]);
// leftWidth 변경 시 Context 업데이트
useEffect(() => {
ctxUpdateRef.current(splitPanelId, { leftWidthPercent: leftWidth });
}, [leftWidth, splitPanelId]);
// 드래그 상태 변경 시 Context 업데이트
// 이전 드래그 상태를 추적하여 드래그 종료 시점을 감지
const prevIsDraggingRef = useRef(false);
useEffect(() => {
const wasJustDragging = prevIsDraggingRef.current && !isDragging;
if (isDragging) {
// 드래그 시작 시: 현재 비율을 초기 비율로 저장
ctxUpdateRef.current(splitPanelId, {
isDragging: true,
initialLeftWidthPercent: leftWidth,
});
} else if (wasJustDragging) {
// 드래그 종료 시: 최종 비율을 초기 비율로 업데이트 (버튼 위치 고정)
ctxUpdateRef.current(splitPanelId, {
isDragging: false,
initialLeftWidthPercent: leftWidth,
});
console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", {
splitPanelId,
finalLeftWidthPercent: leftWidth,
});
}
prevIsDraggingRef.current = isDragging;
}, [isDragging, splitPanelId, leftWidth]);
// 🆕 그룹별 합산된 데이터 계산
const summedLeftData = useMemo(() => {
console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);

View File

@ -58,3 +58,13 @@ export type { SplitPanelLayoutConfig } from "./types";
// 컴포넌트 내보내기
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";
// Resize Context 내보내기 (버튼 등 외부 컴포넌트에서 분할 패널 드래그 리사이즈 상태 활용)
export {
SplitPanelProvider,
useSplitPanel,
useAdjustedPosition,
useSplitPanelAwarePosition,
useAdjustedComponentPosition,
} from "./SplitPanelContext";
export type { SplitPanelResizeContextValue, SplitPanelInfo } from "./SplitPanelContext";