리사이즈 기능 추가 및 상태 관리 개선: RealtimePreviewDynamic 및 TabsWidget에서 컴포넌트 리사이즈 기능을 추가하고, 리사이즈 상태를 관리하는 로직을 개선하여 사용자 경험을 향상시켰습니다. 이를 통해 컴포넌트 크기 조정 시 더 나은 반응성과 정확성을 제공하게 되었습니다.
This commit is contained in:
parent
8cdb8a3047
commit
4781a17b71
|
|
@ -39,6 +39,7 @@ interface RealtimePreviewProps {
|
|||
onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백
|
||||
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백
|
||||
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
||||
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
||||
|
||||
// 버튼 액션을 위한 props
|
||||
screenId?: number;
|
||||
|
|
@ -139,6 +140,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
|
||||
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
|
||||
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||
onResize, // 🆕 리사이즈 콜백
|
||||
}) => {
|
||||
// 🆕 화면 다국어 컨텍스트
|
||||
const { getTranslatedText } = useScreenMultiLang();
|
||||
|
|
@ -146,6 +148,102 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
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(() => {
|
||||
|
|
@ -249,18 +347,27 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
return `${actualHeight}px`;
|
||||
}
|
||||
|
||||
// 1순위: style.height가 있으면 우선 사용 (문자열 그대로 또는 숫자+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;
|
||||
}
|
||||
|
||||
// 2순위: size.height (픽셀)
|
||||
// 3순위: 기본값
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
return `${Math.max(size?.height || 200, 200)}px`;
|
||||
return "200px";
|
||||
}
|
||||
|
||||
// size.height가 있으면 그대로 사용, 없으면 최소 10px
|
||||
return `${size?.height || 10}px`;
|
||||
// 기본 높이
|
||||
return "10px";
|
||||
};
|
||||
|
||||
// layout 타입 컴포넌트인지 확인
|
||||
|
|
@ -405,16 +512,22 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
|
||||
|
||||
// 🆕 리사이즈 크기가 있으면 우선 사용
|
||||
// (size가 업데이트되면 위 useEffect에서 resizeSize를 null로 설정)
|
||||
const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
|
||||
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
|
||||
|
||||
const baseStyle = {
|
||||
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
|
||||
top: `${position.y}px`,
|
||||
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
||||
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
|
||||
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
|
||||
width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용
|
||||
height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음
|
||||
transition:
|
||||
isResizing ? "none" :
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
};
|
||||
|
||||
|
|
@ -546,6 +659,27 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4905,115 +4905,155 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
||||
{/* 🆕 탭 내부 컴포넌트가 선택된 경우 별도 패널 표시 */}
|
||||
{/* 탭 내부 컴포넌트 선택 시에도 UnifiedPropertiesPanel 사용 */}
|
||||
{selectedTabComponentInfo ? (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">탭 내부 컴포넌트 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedTabComponentInfo.component.label || selectedTabComponentInfo.component.componentType}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSelectedTabComponentInfo(null)}
|
||||
>
|
||||
선택 해제
|
||||
</button>
|
||||
</div>
|
||||
{/* DynamicComponentConfigPanel 렌더링 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{(() => {
|
||||
const DynamicConfigPanel = require("@/lib/utils/getComponentConfigPanel").DynamicComponentConfigPanel;
|
||||
const tabComp = selectedTabComponentInfo.component;
|
||||
|
||||
// 탭 내부 컴포넌트를 일반 컴포넌트 형식으로 변환
|
||||
const componentForConfig = {
|
||||
id: tabComp.id,
|
||||
type: "component",
|
||||
componentType: tabComp.componentType,
|
||||
label: tabComp.label,
|
||||
position: tabComp.position,
|
||||
size: tabComp.size,
|
||||
componentConfig: tabComp.componentConfig || {},
|
||||
style: tabComp.style || {},
|
||||
inputType: tabComp.inputType || tabComp.componentConfig?.inputType, // 🆕 inputType 추가
|
||||
widgetType: tabComp.widgetType || tabComp.componentConfig?.widgetType, // 🆕 widgetType 추가
|
||||
(() => {
|
||||
const tabComp = selectedTabComponentInfo.component;
|
||||
|
||||
// 탭 내부 컴포넌트를 ComponentData 형식으로 변환
|
||||
const tabComponentAsComponentData: ComponentData = {
|
||||
id: tabComp.id,
|
||||
type: "component",
|
||||
componentType: tabComp.componentType,
|
||||
label: tabComp.label,
|
||||
position: tabComp.position || { x: 0, y: 0 },
|
||||
size: tabComp.size || { width: 200, height: 100 },
|
||||
componentConfig: tabComp.componentConfig || {},
|
||||
style: tabComp.style || {},
|
||||
} as ComponentData;
|
||||
|
||||
// 탭 내부 컴포넌트용 속성 업데이트 핸들러
|
||||
const updateTabComponentProperty = (componentId: string, path: string, value: any) => {
|
||||
const { tabsComponentId, tabId } = selectedTabComponentInfo;
|
||||
|
||||
setLayout((prevLayout) => {
|
||||
const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId);
|
||||
if (!tabsComponent) return prevLayout;
|
||||
|
||||
const currentConfig = (tabsComponent as any).componentConfig || {};
|
||||
const tabs = currentConfig.tabs || [];
|
||||
|
||||
const updatedTabs = tabs.map((tab: any) => {
|
||||
if (tab.id === tabId) {
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).map((comp: any) => {
|
||||
if (comp.id !== componentId) return comp;
|
||||
|
||||
// path를 파싱하여 중첩 속성 업데이트
|
||||
const pathParts = path.split(".");
|
||||
const newComp = { ...comp };
|
||||
let current: any = newComp;
|
||||
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const part = pathParts[i];
|
||||
if (!current[part]) {
|
||||
current[part] = {};
|
||||
} else {
|
||||
current[part] = { ...current[part] };
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
current[pathParts[pathParts.length - 1]] = value;
|
||||
|
||||
return newComp;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
|
||||
const updatedComponent = {
|
||||
...tabsComponent,
|
||||
componentConfig: { ...currentConfig, tabs: updatedTabs },
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicConfigPanel
|
||||
componentId={tabComp.componentType}
|
||||
component={componentForConfig}
|
||||
config={tabComp.componentConfig || {}}
|
||||
screenTableName={selectedScreen?.tableName}
|
||||
tableColumns={tables.length > 0 ? tables[0].columns : []}
|
||||
menuObjid={selectedScreen?.menuObjid}
|
||||
currentComponent={componentForConfig}
|
||||
onChange={(newConfig: any) => {
|
||||
// componentConfig 전체 업데이트 - 함수형 업데이트로 클로저 문제 해결
|
||||
const { tabsComponentId, tabId, componentId } = selectedTabComponentInfo;
|
||||
|
||||
setLayout((prevLayout) => {
|
||||
const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId);
|
||||
if (!tabsComponent) return prevLayout;
|
||||
// 선택된 컴포넌트 정보 업데이트
|
||||
const updatedComp = updatedTabs
|
||||
.find((t: any) => t.id === tabId)
|
||||
?.components?.find((c: any) => c.id === componentId);
|
||||
if (updatedComp) {
|
||||
setSelectedTabComponentInfo((prev) =>
|
||||
prev ? { ...prev, component: updatedComp } : null
|
||||
);
|
||||
}
|
||||
|
||||
const currentConfig = (tabsComponent as any).componentConfig || {};
|
||||
const tabs = currentConfig.tabs || [];
|
||||
return {
|
||||
...prevLayout,
|
||||
components: prevLayout.components.map((c) =>
|
||||
c.id === tabsComponentId ? updatedComponent : c
|
||||
),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updatedTabs = tabs.map((tab: any) => {
|
||||
if (tab.id === tabId) {
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).map((comp: any) =>
|
||||
comp.id === componentId
|
||||
? { ...comp, componentConfig: newConfig }
|
||||
: comp
|
||||
),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
// 탭 내부 컴포넌트 삭제 핸들러
|
||||
const deleteTabComponent = (componentId: string) => {
|
||||
const { tabsComponentId, tabId } = selectedTabComponentInfo;
|
||||
|
||||
setLayout((prevLayout) => {
|
||||
const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId);
|
||||
if (!tabsComponent) return prevLayout;
|
||||
|
||||
const updatedComponent = {
|
||||
...tabsComponent,
|
||||
componentConfig: {
|
||||
...currentConfig,
|
||||
tabs: updatedTabs,
|
||||
},
|
||||
};
|
||||
const currentConfig = (tabsComponent as any).componentConfig || {};
|
||||
const tabs = currentConfig.tabs || [];
|
||||
|
||||
const newLayout = {
|
||||
...prevLayout,
|
||||
components: prevLayout.components.map((c) =>
|
||||
c.id === tabsComponentId ? updatedComponent : c
|
||||
),
|
||||
};
|
||||
const updatedTabs = tabs.map((tab: any) => {
|
||||
if (tab.id === tabId) {
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).filter((c: any) => c.id !== componentId),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
|
||||
// 선택된 컴포넌트 정보 업데이트
|
||||
const updatedComp = updatedTabs
|
||||
.find((t: any) => t.id === tabId)
|
||||
?.components?.find((c: any) => c.id === componentId);
|
||||
if (updatedComp) {
|
||||
setSelectedTabComponentInfo((prev) =>
|
||||
prev ? { ...prev, component: updatedComp } : null
|
||||
);
|
||||
}
|
||||
const updatedComponent = {
|
||||
...tabsComponent,
|
||||
componentConfig: { ...currentConfig, tabs: updatedTabs },
|
||||
};
|
||||
|
||||
return newLayout;
|
||||
});
|
||||
}}
|
||||
screenTableName={selectedScreen?.tableName}
|
||||
tableColumns={tables.length > 0 ? tables[0].columns : []}
|
||||
setSelectedTabComponentInfo(null);
|
||||
|
||||
return {
|
||||
...prevLayout,
|
||||
components: prevLayout.components.map((c) =>
|
||||
c.id === tabsComponentId ? updatedComponent : c
|
||||
),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-xs text-muted-foreground">탭 내부 컴포넌트</span>
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSelectedTabComponentInfo(null)}
|
||||
>
|
||||
선택 해제
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<UnifiedPropertiesPanel
|
||||
selectedComponent={tabComponentAsComponentData}
|
||||
tables={tables}
|
||||
onUpdateProperty={updateTabComponentProperty}
|
||||
onDeleteComponent={deleteTabComponent}
|
||||
currentTable={tables.length > 0 ? tables[0] : undefined}
|
||||
currentTableName={selectedScreen?.tableName}
|
||||
currentScreenCompanyCode={selectedScreen?.companyCode}
|
||||
onStyleChange={(style) => {
|
||||
updateTabComponentProperty(tabComp.id, "style", style);
|
||||
}}
|
||||
allComponents={layout.components}
|
||||
menuObjid={selectedScreen?.menuObjid}
|
||||
menuObjid={menuObjid}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<UnifiedPropertiesPanel
|
||||
selectedComponent={selectedComponent || undefined}
|
||||
|
|
@ -5377,6 +5417,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
}}
|
||||
// 🆕 리사이즈 핸들러 (10px 스냅 적용됨)
|
||||
onResize={(componentId, newSize) => {
|
||||
setLayout((prevLayout) => {
|
||||
const updatedComponents = prevLayout.components.map((comp) =>
|
||||
comp.id === componentId
|
||||
? { ...comp, size: newSize }
|
||||
: comp
|
||||
);
|
||||
|
||||
const newLayout = {
|
||||
...prevLayout,
|
||||
components: updatedComponents,
|
||||
};
|
||||
|
||||
// saveToHistory는 별도로 호출 (prevLayout 기반)
|
||||
setTimeout(() => saveToHistory(newLayout), 0);
|
||||
return newLayout;
|
||||
});
|
||||
}}
|
||||
// 🆕 탭 내부 컴포넌트 선택 핸들러
|
||||
onSelectTabComponent={(tabId, compId, comp) =>
|
||||
handleSelectTabComponent(component.id, tabId, compId, comp)
|
||||
|
|
@ -5476,6 +5535,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config);
|
||||
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
|
||||
}}
|
||||
// 🆕 자식 컴포넌트 리사이즈 핸들러
|
||||
onResize={(componentId, newSize) => {
|
||||
setLayout((prevLayout) => {
|
||||
const updatedComponents = prevLayout.components.map((comp) =>
|
||||
comp.id === componentId
|
||||
? { ...comp, size: newSize }
|
||||
: comp
|
||||
);
|
||||
|
||||
const newLayout = {
|
||||
...prevLayout,
|
||||
components: updatedComponents,
|
||||
};
|
||||
|
||||
setTimeout(() => saveToHistory(newLayout), 0);
|
||||
return newLayout;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { Folder, Plus, Move, Settings, Trash2 } from "lucide-react";
|
||||
|
|
@ -21,8 +21,26 @@ const TabsDesignEditor: React.FC<{
|
|||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
// 리사이즈 상태
|
||||
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
|
||||
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [lastResizedCompId, setLastResizedCompId] = useState<string | null>(null);
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
|
||||
// 🆕 탭 컴포넌트 size가 업데이트되면 resizeSize 초기화
|
||||
useEffect(() => {
|
||||
if (resizeSize && lastResizedCompId && !resizingCompId) {
|
||||
const targetComp = activeTab?.components?.find(c => c.id === lastResizedCompId);
|
||||
if (targetComp &&
|
||||
targetComp.size?.width === resizeSize.width &&
|
||||
targetComp.size?.height === resizeSize.height) {
|
||||
setResizeSize(null);
|
||||
setLastResizedCompId(null);
|
||||
}
|
||||
}
|
||||
}, [tabs, activeTab, resizeSize, lastResizedCompId, resizingCompId]);
|
||||
|
||||
const getTabStyle = (tab: TabItem) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
|
|
@ -157,6 +175,110 @@ const TabsDesignEditor: React.FC<{
|
|||
[activeTabId, component, onUpdateComponent, tabs]
|
||||
);
|
||||
|
||||
// 10px 단위 스냅 함수
|
||||
const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []);
|
||||
|
||||
// 리사이즈 시작 핸들러
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent, comp: TabInlineComponent, direction: "e" | "s" | "se") => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const startMouseX = e.clientX;
|
||||
const startMouseY = e.clientY;
|
||||
const startWidth = comp.size?.width || 200;
|
||||
const startHeight = comp.size?.height || 100;
|
||||
|
||||
setResizingCompId(comp.id);
|
||||
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));
|
||||
}
|
||||
|
||||
// 🆕 탭 컴포넌트 크기 업데이트 먼저 실행
|
||||
if (onUpdateComponent) {
|
||||
const updatedTabs = tabs.map((tab) => {
|
||||
if (tab.id === activeTabId) {
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).map((c) =>
|
||||
c.id === comp.id
|
||||
? {
|
||||
...c,
|
||||
size: {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
},
|
||||
}
|
||||
: c
|
||||
),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
|
||||
onUpdateComponent({
|
||||
...component,
|
||||
componentConfig: {
|
||||
...component.componentConfig,
|
||||
tabs: updatedTabs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 리사이즈 상태 해제 (resizeSize는 마지막 크기 유지, lastResizedCompId 설정)
|
||||
setLastResizedCompId(comp.id);
|
||||
setResizingCompId(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[activeTabId, component, onUpdateComponent, tabs]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background">
|
||||
{/* 탭 헤더 */}
|
||||
|
|
@ -205,6 +327,15 @@ const TabsDesignEditor: React.FC<{
|
|||
{activeTab.components.map((comp: TabInlineComponent) => {
|
||||
const isSelected = selectedTabComponentId === comp.id;
|
||||
const isDragging = draggingCompId === comp.id;
|
||||
const isResizing = resizingCompId === comp.id;
|
||||
|
||||
// 드래그/리사이즈 중 표시할 크기
|
||||
// resizeSize가 있고 해당 컴포넌트이면 resizeSize 우선 사용 (레이아웃 업데이트 반영 전까지)
|
||||
const compWidth = comp.size?.width || 200;
|
||||
const compHeight = comp.size?.height || 100;
|
||||
const isResizingThis = (resizingCompId === comp.id || lastResizedCompId === comp.id) && resizeSize;
|
||||
const displayWidth = isResizingThis ? resizeSize!.width : compWidth;
|
||||
const displayHeight = isResizingThis ? resizeSize!.height : compHeight;
|
||||
|
||||
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
|
||||
const componentData = {
|
||||
|
|
@ -213,7 +344,7 @@ const TabsDesignEditor: React.FC<{
|
|||
componentType: comp.componentType,
|
||||
label: comp.label,
|
||||
position: comp.position || { x: 0, y: 0 },
|
||||
size: comp.size || { width: 200, height: 100 },
|
||||
size: { width: displayWidth, height: displayHeight },
|
||||
componentConfig: comp.componentConfig || {},
|
||||
style: comp.style || {},
|
||||
};
|
||||
|
|
@ -279,23 +410,46 @@ const TabsDesignEditor: React.FC<{
|
|||
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-b border bg-white shadow-sm overflow-hidden pointer-events-none",
|
||||
"relative rounded-b border bg-white shadow-sm overflow-hidden",
|
||||
isSelected
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-gray-200",
|
||||
isDragging && "opacity-80 shadow-lg",
|
||||
!isDragging && "transition-all"
|
||||
(isDragging || isResizing) && "opacity-80 shadow-lg",
|
||||
!(isDragging || isResizing) && "transition-all"
|
||||
)}
|
||||
style={{
|
||||
width: comp.size?.width || 200,
|
||||
height: comp.size?.height || 100,
|
||||
width: displayWidth,
|
||||
height: displayHeight,
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={componentData as any}
|
||||
isDesignMode={true}
|
||||
formData={{}}
|
||||
/>
|
||||
<div className="h-full w-full pointer-events-none">
|
||||
<DynamicComponentRenderer
|
||||
component={componentData as any}
|
||||
isDesignMode={true}
|
||||
formData={{}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */}
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* 오른쪽 가장자리 (너비 조절) */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-2 h-full cursor-ew-resize pointer-events-auto z-10 hover:bg-primary/10"
|
||||
onMouseDown={(e) => handleResizeStart(e, comp, "e")}
|
||||
/>
|
||||
{/* 아래 가장자리 (높이 조절) */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize pointer-events-auto z-10 hover:bg-primary/10"
|
||||
onMouseDown={(e) => handleResizeStart(e, comp, "s")}
|
||||
/>
|
||||
{/* 오른쪽 아래 모서리 (너비+높이 조절) */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-3 h-3 cursor-nwse-resize pointer-events-auto z-20 hover:bg-primary/20"
|
||||
onMouseDown={(e) => handleResizeStart(e, comp, "se")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue