From e231052d9ffacf9e22c2e3adf2971b527a112ef3 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 11 Mar 2026 11:53:29 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B0=98=EC=9D=91=ED=98=95=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EA=B7=BC=EB=B3=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?-=20DesktopCanvasRenderer=20=EC=A0=9C=EA=B1=B0,=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EA=B3=A0=EC=A0=95=20=ED=94=BD=EC=85=80=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResponsiveGridRenderer: DesktopCanvasRenderer(transform:scale) 제거, 모든 화면 flex 퍼센트 레이아웃 강제 - ResponsiveGridRenderer: useMemo Hook 제거로 Hook 순서 에러 해결 - ResponsiveGridRenderer: 모든 비-버튼 컴포넌트 flexGrow:1 적용 (수주관리처럼 남는 공간 채움) - ResponsiveGridRenderer: 캔버스 높이 80% 이상 컴포넌트는 flex:1로 세로 공간도 채움 - DynamicComponentRenderer: 런타임 모드에서 size.width/height를 고정 픽셀 대신 100% 사용 - ResponsiveSplitPanel: 3단계 브레이크포인트 (데스크톱 1280+, 태블릿 768-1279, 모바일 <768) Made-with: Cursor --- .../common/ResponsiveSplitPanel.tsx | 167 ++++++++++----- .../screen/ResponsiveGridRenderer.tsx | 196 +++++------------- .../lib/registry/DynamicComponentRenderer.tsx | 10 +- 3 files changed, 178 insertions(+), 195 deletions(-) diff --git a/frontend/components/common/ResponsiveSplitPanel.tsx b/frontend/components/common/ResponsiveSplitPanel.tsx index 860254a1..7b0f6031 100644 --- a/frontend/components/common/ResponsiveSplitPanel.tsx +++ b/frontend/components/common/ResponsiveSplitPanel.tsx @@ -6,36 +6,45 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export interface ResponsiveSplitPanelProps { - /** 좌측 패널 콘텐츠 */ left: ReactNode; - /** 우측 패널 콘텐츠 */ right: ReactNode; - /** 좌측 패널 제목 (모바일 접기/펼치기 시 표시) */ leftTitle?: string; - /** 좌측 패널 기본 너비 (%, 기본: 25) */ leftWidth?: number; - /** 좌측 패널 최소 너비 (%, 기본: 10) */ minLeftWidth?: number; - /** 좌측 패널 최대 너비 (%, 기본: 50) */ maxLeftWidth?: number; - /** 리사이저 표시 여부 (기본: true) */ showResizer?: boolean; - /** 모바일에서 좌측 패널 기본 접힘 여부 (기본: true) */ collapsedOnMobile?: boolean; - /** 컨테이너 높이 (기본: "100%") */ height?: string; - /** 추가 className */ className?: string; - /** 좌측 패널 추가 className */ leftClassName?: string; - /** 우측 패널 추가 className */ rightClassName?: string; } -const MOBILE_BREAKPOINT = 1024; +type ViewMode = "desktop" | "tablet" | "mobile"; + +const DESKTOP_BREAKPOINT = 1280; +const TABLET_BREAKPOINT = 768; + +function getViewMode(width: number): ViewMode { + if (width >= DESKTOP_BREAKPOINT) return "desktop"; + if (width >= TABLET_BREAKPOINT) return "tablet"; + return "mobile"; +} + +function getAdaptiveLeftWidth( + baseWidth: number, + containerWidth: number, + minPx: number +): number { + const currentPx = (baseWidth / 100) * containerWidth; + if (currentPx < minPx) { + return Math.min((minPx / containerWidth) * 100, 50); + } + return baseWidth; +} export function ResponsiveSplitPanel({ left, @@ -51,28 +60,76 @@ export function ResponsiveSplitPanel({ leftClassName, rightClassName, }: ResponsiveSplitPanelProps) { - const [leftWidth, setLeftWidth] = useState(initialLeftWidth); - const [isMobileView, setIsMobileView] = useState(false); + const [userLeftWidth, setUserLeftWidth] = useState(initialLeftWidth); + const [viewMode, setViewMode] = useState("desktop"); const [leftCollapsed, setLeftCollapsed] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); const containerRef = useRef(null); const isDraggingRef = useRef(false); + const prevViewModeRef = useRef("desktop"); - // 뷰포트 감지 + // 컨테이너 크기 + 뷰 모드 실시간 감지 useEffect(() => { - const checkMobile = () => { - const mobile = window.innerWidth < MOBILE_BREAKPOINT; - setIsMobileView(mobile); - if (mobile && collapsedOnMobile) { - setLeftCollapsed(true); - } - }; + const container = containerRef.current; + if (!container) return; - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const w = entry.contentRect.width; + setContainerWidth(w); + + const newMode = getViewMode(window.innerWidth); + setViewMode((prev) => { + if (prev !== newMode) { + if (newMode === "mobile" && collapsedOnMobile) { + setLeftCollapsed(true); + } else if (newMode === "tablet") { + setLeftCollapsed(true); + } else if (newMode === "desktop" && prev !== "desktop") { + setLeftCollapsed(false); + } + } + return newMode; + }); + }); + + observer.observe(container); + setContainerWidth(container.offsetWidth); + const initialMode = getViewMode(window.innerWidth); + setViewMode(initialMode); + if (initialMode === "mobile" && collapsedOnMobile) { + setLeftCollapsed(true); + } else if (initialMode === "tablet") { + setLeftCollapsed(true); + } + + return () => observer.disconnect(); }, [collapsedOnMobile]); - // 데스크톱 리사이저 + // window resize도 감지 (viewport 변화) + useEffect(() => { + const handleResize = () => { + const newMode = getViewMode(window.innerWidth); + setViewMode((prev) => { + if (prev !== newMode) { + if (newMode === "mobile" && collapsedOnMobile) { + setLeftCollapsed(true); + } else if (newMode === "tablet") { + setLeftCollapsed(true); + } else if (newMode === "desktop") { + setLeftCollapsed(false); + } + } + return newMode; + }); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [collapsedOnMobile]); + + // 리사이저 const handleMouseDown = useCallback(() => { isDraggingRef.current = true; document.body.style.cursor = "col-resize"; @@ -85,7 +142,7 @@ export function ResponsiveSplitPanel({ const rect = containerRef.current.getBoundingClientRect(); const pct = ((e.clientX - rect.left) / rect.width) * 100; if (pct >= minLeftWidth && pct <= maxLeftWidth) { - setLeftWidth(pct); + setUserLeftWidth(pct); } }, [minLeftWidth, maxLeftWidth] @@ -106,11 +163,17 @@ export function ResponsiveSplitPanel({ }; }, [handleMouseMove, handleMouseUp]); - // --- 모바일 레이아웃: 세로 스택 --- - if (isMobileView) { + // 좌측 패널 최소 px 보장 (200px 이하로 줄어들지 않게) + const MIN_LEFT_PX = 200; + const effectiveLeftWidth = + containerWidth > 0 + ? getAdaptiveLeftWidth(userLeftWidth, containerWidth, MIN_LEFT_PX) + : userLeftWidth; + + // --- 모바일: 세로 스택 --- + if (viewMode === "mobile") { return ( -
- {/* 좌측 패널 토글 헤더 */} +
- {/* 좌측 패널 (접기/펼치기) */} {!leftCollapsed && ( -
+
{left}
)} - {/* 우측 패널 (항상 표시) */}
{right}
@@ -147,39 +203,42 @@ export function ResponsiveSplitPanel({ ); } - // --- 데스크톱 레이아웃: 좌우 분할 --- + // --- 태블릿 + 데스크톱: 좌우 분할 --- return (
- {/* 좌측 패널 (접기 가능) */} + {/* 좌측 패널 */} {!leftCollapsed ? ( <>
{left}
- {/* 리사이저 */} - {showResizer && ( + {/* 리사이저 (데스크톱만) */} + {showResizer && viewMode === "desktop" && (
)} ) : ( -
+