diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index f556dae2..8510d627 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -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 ( -
+
{/* λ ˆμ΄μ•„μ›ƒ μ€€λΉ„ 쀑 λ‘œλ”© ν‘œμ‹œ */} {!layoutReady && (
@@ -358,7 +356,6 @@ function ScreenViewPage() { return isButton; }); - topLevelComponents.forEach((component) => { const isButton = (component.type === "component" && @@ -799,7 +796,9 @@ function ScreenViewPageWrapper() { return ( - + + + ); diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index e11b03a0..480b3ddd 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -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 = ( : component; return ( - -
- {/* ν…Œμ΄λΈ” μ˜΅μ…˜ νˆ΄λ°” */} - - - {/* 메인 컨텐츠 */} -
- {/* 라벨이 μžˆλŠ” 경우 ν‘œμ‹œ (데이터 ν…Œμ΄λΈ” μ œμ™Έ) */} - {shouldShowLabel && ( - - )} - - {/* μ‹€μ œ μœ„μ ― - μƒμœ„μ—μ„œ 라벨을 λ Œλ”λ§ν–ˆμœΌλ―€λ‘œ μžμ‹μ€ 라벨 μˆ¨κΉ€ */} -
{renderInteractiveWidget(componentForRendering)}
-
-
- - {/* κ°œμ„ λœ 검증 νŒ¨λ„ (선택적 ν‘œμ‹œ) */} - {showValidationPanel && enhancedValidation && ( -
- { - const success = await enhancedValidation.saveForm(); - if (success) { - toast.success("데이터가 μ„±κ³΅μ μœΌλ‘œ μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€!"); - } - }} - canSave={enhancedValidation.canSave} - compact={true} - showDetails={false} - /> -
- )} - - {/* λͺ¨λ‹¬ ν™”λ©΄ */} - { - setPopupScreen(null); - setPopupFormData({}); // νŒμ—… 닫을 λ•Œ formData도 μ΄ˆκΈ°ν™” - }}> - - - {popupScreen?.title || "상세 정보"} - + + +
+ {/* ν…Œμ΄λΈ” μ˜΅μ…˜ νˆ΄λ°” */} + -
- {popupLoading ? ( -
-
화면을 λΆˆλŸ¬μ˜€λŠ” 쀑...
-
- ) : popupLayout.length > 0 ? ( -
- {/* νŒμ—…μ—μ„œλ„ μ‹€μ œ μœ„μΉ˜μ™€ 크기둜 λ Œλ”λ§ */} - {popupLayout.map((popupComponent) => ( -
- {/* 🎯 핡심 μˆ˜μ •: νŒμ—… μ „μš© formData μ‚¬μš© */} - { - console.log("πŸ’Ύ νŒμ—… formData μ—…λ°μ΄νŠΈ:", { - fieldName, - value, - valueType: typeof value, - prevFormData: popupFormData - }); - - setPopupFormData(prev => ({ - ...prev, - [fieldName]: value - })); - }} - /> -
- ))} -
- ) : ( -
-
ν™”λ©΄ 데이터가 μ—†μŠ΅λ‹ˆλ‹€.
-
+ {/* 메인 컨텐츠 */} +
+ {/* 라벨이 μžˆλŠ” 경우 ν‘œμ‹œ (데이터 ν…Œμ΄λΈ” μ œμ™Έ) */} + {shouldShowLabel && ( + )} + + {/* μ‹€μ œ μœ„μ ― - μƒμœ„μ—μ„œ 라벨을 λ Œλ”λ§ν–ˆμœΌλ―€λ‘œ μžμ‹μ€ 라벨 μˆ¨κΉ€ */} +
{renderInteractiveWidget(componentForRendering)}
- -
-
+
+ + {/* κ°œμ„ λœ 검증 νŒ¨λ„ (선택적 ν‘œμ‹œ) */} + {showValidationPanel && enhancedValidation && ( +
+ { + const success = await enhancedValidation.saveForm(); + if (success) { + toast.success("데이터가 μ„±κ³΅μ μœΌλ‘œ μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€!"); + } + }} + canSave={enhancedValidation.canSave} + compact={true} + showDetails={false} + /> +
+ )} + + {/* λͺ¨λ‹¬ ν™”λ©΄ */} + { + setPopupScreen(null); + setPopupFormData({}); // νŒμ—… 닫을 λ•Œ formData도 μ΄ˆκΈ°ν™” + }}> + + + {popupScreen?.title || "상세 정보"} + + +
+ {popupLoading ? ( +
+
화면을 λΆˆλŸ¬μ˜€λŠ” 쀑...
+
+ ) : popupLayout.length > 0 ? ( +
+ {/* νŒμ—…μ—μ„œλ„ μ‹€μ œ μœ„μΉ˜μ™€ 크기둜 λ Œλ”λ§ */} + {popupLayout.map((popupComponent) => ( +
+ {/* 🎯 핡심 μˆ˜μ •: νŒμ—… μ „μš© formData μ‚¬μš© */} + { + console.log("πŸ’Ύ νŒμ—… formData μ—…λ°μ΄νŠΈ:", { + fieldName, + value, + valueType: typeof value, + prevFormData: popupFormData + }); + + setPopupFormData(prev => ({ + ...prev, + [fieldName]: value + })); + }} + /> +
+ ))} +
+ ) : ( +
+
ν™”λ©΄ 데이터가 μ—†μŠ΅λ‹ˆλ‹€.
+
+ )} +
+
+
+ + ); }; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index f1ca6e7d..b58a6a1f 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -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 = ({ // ν”Œλ‘œμš° μœ„μ ―μ˜ μ‹€μ œ 높이 μΈ‘μ • 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 = ({ }, [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 = ({ 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 = ({ onDragEnd={handleDragEnd} > {/* μ»΄ν¬λ„ŒνŠΈ νƒ€μž…λ³„ λ Œλ”λ§ */} -
+
{/* μ˜μ—­ νƒ€μž… */} {type === "area" && renderArea(component, children)} @@ -549,16 +658,16 @@ export const RealtimePreviewDynamic: React.FC = ({ return (
- +
); })()} {/* νƒ­ μ»΄ν¬λ„ŒνŠΈ νƒ€μž… */} - {(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 = ({ {tab.label || `νƒ­ ${index + 1}`} {tab.screenName && ( - - ({tab.screenName}) - + ({tab.screenName}) )} ))} @@ -632,28 +739,29 @@ export const RealtimePreviewDynamic: React.FC = ({ )} {/* μ»΄ν¬λ„ŒνŠΈ νƒ€μž… - λ ˆμ§€μŠ€νŠΈλ¦¬ 기반 λ Œλ”λ§ (Section Paper, Section Card λ“±) */} - {type === "component" && (() => { - const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); - return ( - - {children} - - ); - })()} + {type === "component" && + (() => { + const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); + return ( + + {children} + + ); + })()} {/* μœ„μ ― νƒ€μž… - 동적 λ Œλ”λ§ (파일 μ»΄ν¬λ„ŒνŠΈ μ œμ™Έ) */} {type === "widget" && !isFileComponent(component) && (
- void; } @@ -262,14 +263,145 @@ export const RealtimePreviewDynamic: React.FC = ({ } : 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(); + 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, }; // 크기 μ •λ³΄λŠ” ν•„μš”μ‹œμ—λ§Œ 디버깅 (개발 쀑 문제 λ°œμƒ μ‹œ 주석 ν•΄μ œ) diff --git a/frontend/components/screen/SplitPanelAwareWrapper.tsx b/frontend/components/screen/SplitPanelAwareWrapper.tsx new file mode 100644 index 00000000..9dae37a6 --- /dev/null +++ b/frontend/components/screen/SplitPanelAwareWrapper.tsx @@ -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 = ({ + 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 ( +
+ {children} +
+ ); +}; + +export default SplitPanelAwareWrapper; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx new file mode 100644 index 00000000..fe3a9327 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx @@ -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; + + // λΆ„ν•  νŒ¨λ„ 등둝/ν•΄μ œ/μ—…λ°μ΄νŠΈ + registerSplitPanel: (id: string, info: Omit) => void; + unregisterSplitPanel: (id: string) => void; + updateSplitPanel: (id: string, updates: Partial) => 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(null); + +/** + * SplitPanelResize Context Provider + * 슀크린 λΉŒλ” λ ˆλ²¨μ—μ„œ κ°μ‹Έμ„œ μ‚¬μš© + */ +export const SplitPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + // λ“±λ‘λœ λΆ„ν•  νŒ¨λ„λ“€ + const splitPanelsRef = useRef>(new Map()); + const [, forceUpdate] = useState(0); + + // λ ˆκ±°μ‹œ ν˜Έν™˜μš© μƒνƒœ + const [legacyLeftWidthPercent, setLegacyLeftWidthPercent] = useState(30); + const [legacyContainerRect, setLegacyContainerRect] = useState(null); + const [legacyIsDragging, setLegacyIsDragging] = useState(false); + const [legacySplitPanelId, setLegacySplitPanelId] = useState(null); + + // λΆ„ν•  νŒ¨λ„ 등둝 + const registerSplitPanel = useCallback((id: string, info: Omit) => { + 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) => { + 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 = { isDragging: dragging }; + if (dragging) { + updates.initialLeftWidthPercent = panel.leftWidthPercent; + } + splitPanelsRef.current.set(firstPanelId, { ...panel, ...updates }); + } + } + forceUpdate((n) => n + 1); + }, []); + + const value = useMemo( + () => ({ + 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 {children}; +}; + +/** + * 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; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index e8014327..9d1d0811 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -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 const [leftWidth, setLeftWidth] = useState(splitRatio); const containerRef = React.useRef(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); diff --git a/frontend/lib/registry/components/split-panel-layout/index.ts b/frontend/lib/registry/components/split-panel-layout/index.ts index 080eab8a..9bbb56cb 100644 --- a/frontend/lib/registry/components/split-panel-layout/index.ts +++ b/frontend/lib/registry/components/split-panel-layout/index.ts @@ -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";