fix: 반응형 렌더링 근본 수정 - DesktopCanvasRenderer 제거, 런타임 고정 픽셀 제거

- 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
This commit is contained in:
DDD1542 2026-03-11 11:53:29 +09:00
parent f3bbe4af7f
commit e231052d9f
3 changed files with 178 additions and 195 deletions

View File

@ -6,36 +6,45 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface ResponsiveSplitPanelProps { export interface ResponsiveSplitPanelProps {
/** 좌측 패널 콘텐츠 */
left: ReactNode; left: ReactNode;
/** 우측 패널 콘텐츠 */
right: ReactNode; right: ReactNode;
/** 좌측 패널 제목 (모바일 접기/펼치기 시 표시) */
leftTitle?: string; leftTitle?: string;
/** 좌측 패널 기본 너비 (%, 기본: 25) */
leftWidth?: number; leftWidth?: number;
/** 좌측 패널 최소 너비 (%, 기본: 10) */
minLeftWidth?: number; minLeftWidth?: number;
/** 좌측 패널 최대 너비 (%, 기본: 50) */
maxLeftWidth?: number; maxLeftWidth?: number;
/** 리사이저 표시 여부 (기본: true) */
showResizer?: boolean; showResizer?: boolean;
/** 모바일에서 좌측 패널 기본 접힘 여부 (기본: true) */
collapsedOnMobile?: boolean; collapsedOnMobile?: boolean;
/** 컨테이너 높이 (기본: "100%") */
height?: string; height?: string;
/** 추가 className */
className?: string; className?: string;
/** 좌측 패널 추가 className */
leftClassName?: string; leftClassName?: string;
/** 우측 패널 추가 className */
rightClassName?: string; 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({ export function ResponsiveSplitPanel({
left, left,
@ -51,28 +60,76 @@ export function ResponsiveSplitPanel({
leftClassName, leftClassName,
rightClassName, rightClassName,
}: ResponsiveSplitPanelProps) { }: ResponsiveSplitPanelProps) {
const [leftWidth, setLeftWidth] = useState(initialLeftWidth); const [userLeftWidth, setUserLeftWidth] = useState(initialLeftWidth);
const [isMobileView, setIsMobileView] = useState(false); const [viewMode, setViewMode] = useState<ViewMode>("desktop");
const [leftCollapsed, setLeftCollapsed] = useState(false); const [leftCollapsed, setLeftCollapsed] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
const prevViewModeRef = useRef<ViewMode>("desktop");
// 뷰포트 감지 // 컨테이너 크기 + 뷰 모드 실시간 감지
useEffect(() => { useEffect(() => {
const checkMobile = () => { const container = containerRef.current;
const mobile = window.innerWidth < MOBILE_BREAKPOINT; if (!container) return;
setIsMobileView(mobile);
if (mobile && collapsedOnMobile) {
setLeftCollapsed(true);
}
};
checkMobile(); const observer = new ResizeObserver((entries) => {
window.addEventListener("resize", checkMobile); const entry = entries[0];
return () => window.removeEventListener("resize", checkMobile); 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]); }, [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(() => { const handleMouseDown = useCallback(() => {
isDraggingRef.current = true; isDraggingRef.current = true;
document.body.style.cursor = "col-resize"; document.body.style.cursor = "col-resize";
@ -85,7 +142,7 @@ export function ResponsiveSplitPanel({
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const pct = ((e.clientX - rect.left) / rect.width) * 100; const pct = ((e.clientX - rect.left) / rect.width) * 100;
if (pct >= minLeftWidth && pct <= maxLeftWidth) { if (pct >= minLeftWidth && pct <= maxLeftWidth) {
setLeftWidth(pct); setUserLeftWidth(pct);
} }
}, },
[minLeftWidth, maxLeftWidth] [minLeftWidth, maxLeftWidth]
@ -106,11 +163,17 @@ export function ResponsiveSplitPanel({
}; };
}, [handleMouseMove, handleMouseUp]); }, [handleMouseMove, handleMouseUp]);
// --- 모바일 레이아웃: 세로 스택 --- // 좌측 패널 최소 px 보장 (200px 이하로 줄어들지 않게)
if (isMobileView) { const MIN_LEFT_PX = 200;
const effectiveLeftWidth =
containerWidth > 0
? getAdaptiveLeftWidth(userLeftWidth, containerWidth, MIN_LEFT_PX)
: userLeftWidth;
// --- 모바일: 세로 스택 ---
if (viewMode === "mobile") {
return ( return (
<div className={cn("flex flex-col gap-0", className)} style={{ height }}> <div ref={containerRef} className={cn("flex flex-col gap-0", className)} style={{ height }}>
{/* 좌측 패널 토글 헤더 */}
<button <button
onClick={() => setLeftCollapsed(!leftCollapsed)} onClick={() => setLeftCollapsed(!leftCollapsed)}
className={cn( className={cn(
@ -127,19 +190,12 @@ export function ResponsiveSplitPanel({
)} )}
</button> </button>
{/* 좌측 패널 (접기/펼치기) */}
{!leftCollapsed && ( {!leftCollapsed && (
<div <div className={cn("max-h-[40vh] overflow-y-auto border-b", leftClassName)}>
className={cn(
"max-h-[40vh] overflow-y-auto border-b",
leftClassName
)}
>
{left} {left}
</div> </div>
)} )}
{/* 우측 패널 (항상 표시) */}
<div className={cn("min-h-0 flex-1 overflow-y-auto", rightClassName)}> <div className={cn("min-h-0 flex-1 overflow-y-auto", rightClassName)}>
{right} {right}
</div> </div>
@ -147,39 +203,42 @@ export function ResponsiveSplitPanel({
); );
} }
// --- 데스크톱 레이아웃: 좌우 분할 --- // --- 태블릿 + 데스크톱: 좌우 분할 ---
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={cn("flex gap-0 overflow-hidden", className)} className={cn("flex gap-0 overflow-hidden", className)}
style={{ height }} style={{ height }}
> >
{/* 좌측 패널 (접기 가능) */} {/* 좌측 패널 */}
{!leftCollapsed ? ( {!leftCollapsed ? (
<> <>
<div <div
style={{ width: `${leftWidth}%` }} style={{ width: `${effectiveLeftWidth}%` }}
className={cn("flex h-full flex-col overflow-hidden pr-3", leftClassName)} className={cn(
"flex h-full flex-col overflow-hidden border-r pr-1 transition-[width] duration-200",
leftClassName
)}
> >
<div className="flex-1 overflow-y-auto">{left}</div> <div className="flex-1 overflow-y-auto">{left}</div>
</div> </div>
{/* 리사이저 */} {/* 리사이저 (데스크톱만) */}
{showResizer && ( {showResizer && viewMode === "desktop" && (
<div <div
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
className="group hover:bg-accent/50 relative flex h-full w-3 shrink-0 cursor-col-resize items-center justify-center border-r transition-colors" className="group hover:bg-accent/50 relative flex h-full w-2 shrink-0 cursor-col-resize items-center justify-center transition-colors"
> >
<GripVertical className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-colors" /> <GripVertical className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-colors" />
</div> </div>
)} )}
</> </>
) : ( ) : (
<div className="flex h-full w-8 shrink-0 items-start justify-center border-r pt-2"> <div className="flex h-full w-10 shrink-0 flex-col items-center border-r pt-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-7 w-7"
onClick={() => setLeftCollapsed(false)} onClick={() => setLeftCollapsed(false)}
title={`${leftTitle} 열기`} title={`${leftTitle} 열기`}
> >
@ -191,11 +250,15 @@ export function ResponsiveSplitPanel({
{/* 우측 패널 */} {/* 우측 패널 */}
<div <div
style={{ style={{
width: leftCollapsed ? "calc(100% - 32px)" : `${100 - leftWidth - 1}%`, width: leftCollapsed
? "calc(100% - 40px)"
: `${100 - effectiveLeftWidth}%`,
}} }}
className={cn("flex h-full flex-col overflow-hidden pl-3", rightClassName)} className={cn(
"flex h-full flex-col overflow-hidden pl-2 transition-[width] duration-200",
rightClassName
)}
> >
{/* 데스크톱 접기 버튼 */}
{!leftCollapsed && ( {!leftCollapsed && (
<div className="mb-1 flex justify-start"> <div className="mb-1 flex justify-start">
<Button <Button

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useMemo, useRef, useState, useEffect } from "react"; import React, { useRef, useState, useEffect } from "react";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { useResponsive } from "@/lib/hooks/useResponsive"; import { useResponsive } from "@/lib/hooks/useResponsive";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -111,11 +111,6 @@ interface ProcessedRow {
normalComps: ComponentData[]; normalComps: ComponentData[];
} }
/**
* + .
* transform: scale .
* .
*/
function FullWidthOverlayRow({ function FullWidthOverlayRow({
main, main,
overlayComps, overlayComps,
@ -150,8 +145,6 @@ function FullWidthOverlayRow({
const maxBtnH = Math.max( const maxBtnH = Math.max(
...overlayComps.map((c) => c.size?.height || 40) ...overlayComps.map((c) => c.size?.height || 40)
); );
// 버튼 중심 보정: 스케일 축소 시 버튼이 작아지므로 중심 위치가 위로 올라감
// 디자이너와 동일한 중심-대-중심 정렬을 유지하기 위해 Y 오프셋 보정
const yOffset = rawYOffset + (maxBtnH / 2) * (1 - scale); const yOffset = rawYOffset + (maxBtnH / 2) * (1 - scale);
return ( return (
@ -209,144 +202,57 @@ function FullWidthOverlayRow({
); );
} }
/**
* 데스크톱: 원본 (transform: scale )
*
*/
function DesktopCanvasRenderer({
components,
canvasWidth,
canvasHeight,
renderComponent,
}: ResponsiveGridRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerW, setContainerW] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w && w > 0) setContainerW(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const topLevel = components.filter((c) => !c.parentId);
const scale = containerW > 0 ? Math.min(containerW / canvasWidth, 1) : 1;
const scaledHeight = canvasHeight * scale;
return (
<div
ref={containerRef}
data-screen-runtime="true"
className="bg-background relative w-full overflow-x-hidden"
style={{ minHeight: `${scaledHeight}px` }}
>
<div
style={{
width: `${canvasWidth}px`,
height: `${canvasHeight}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
position: "relative",
}}
>
{topLevel.map((component) => {
const typeId = getComponentTypeId(component);
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: component.size?.width ? `${component.size.width}px` : "auto",
height: component.size?.height ? `${component.size.height}px` : "auto",
zIndex: component.position.z || 1,
}}
>
{renderComponent(component)}
</div>
);
})}
</div>
</div>
);
}
export function ResponsiveGridRenderer({ export function ResponsiveGridRenderer({
components, components,
canvasWidth, canvasWidth,
canvasHeight, canvasHeight,
renderComponent, renderComponent,
}: ResponsiveGridRendererProps) { }: ResponsiveGridRendererProps) {
const { isMobile, isTablet } = useResponsive(); const { isMobile } = useResponsive();
// 데스크톱: 원본 캔버스 좌표 유지 (스케일링)
// 풀위드스 컴포넌트(테이블, 스플릿패널 등)가 있으면 flex 레이아웃 사용
const topLevel = components.filter((c) => !c.parentId); const topLevel = components.filter((c) => !c.parentId);
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
const useCanvasLayout = !isMobile && !isTablet && !hasFullWidthComponent;
if (useCanvasLayout) { const rows = groupComponentsIntoRows(topLevel);
return ( const processedRows: ProcessedRow[] = [];
<DesktopCanvasRenderer
components={components}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={renderComponent}
/>
);
}
const processedRows = useMemo(() => { for (const row of rows) {
const rows = groupComponentsIntoRows(topLevel); const fullWidthComps: ComponentData[] = [];
const normalComps: ComponentData[] = [];
const result: ProcessedRow[] = []; for (const comp of row) {
for (const row of rows) { if (isFullWidthComponent(comp)) {
const fullWidthComps: ComponentData[] = []; fullWidthComps.push(comp);
const normalComps: ComponentData[] = [];
for (const comp of row) {
if (isFullWidthComponent(comp)) {
fullWidthComps.push(comp);
} else {
normalComps.push(comp);
}
}
if (fullWidthComps.length > 0 && normalComps.length > 0) {
for (const fwComp of fullWidthComps) {
result.push({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: normalComps,
normalComps: [],
});
}
} else if (fullWidthComps.length > 0) {
for (const fwComp of fullWidthComps) {
result.push({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: [],
normalComps: [],
});
}
} else { } else {
result.push({ normalComps.push(comp);
type: "normal",
overlayComps: [],
normalComps,
});
} }
} }
return result;
}, [topLevel]); if (fullWidthComps.length > 0 && normalComps.length > 0) {
for (const fwComp of fullWidthComps) {
processedRows.push({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: normalComps,
normalComps: [],
});
}
} else if (fullWidthComps.length > 0) {
for (const fwComp of fullWidthComps) {
processedRows.push({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: [],
normalComps: [],
});
}
} else {
processedRows.push({
type: "normal",
overlayComps: [],
normalComps,
});
}
}
return ( return (
<div <div
@ -371,12 +277,18 @@ export function ResponsiveGridRenderer({
const allButtons = normalComps.every((c) => isButtonComponent(c)); const allButtons = normalComps.every((c) => isButtonComponent(c));
const gap = isMobile ? 8 : allButtons ? 8 : getRowGap(normalComps, canvasWidth); const gap = isMobile ? 8 : allButtons ? 8 : getRowGap(normalComps, canvasWidth);
const hasFlexHeightComp = normalComps.some((c) => {
const h = c.size?.height || 0;
return h / canvasHeight >= 0.8;
});
return ( return (
<div <div
key={`row-${rowIndex}`} key={`row-${rowIndex}`}
className={cn( className={cn(
"flex w-full flex-shrink-0 flex-wrap overflow-hidden", "flex w-full flex-wrap overflow-hidden",
allButtons && "justify-end px-2 py-1" allButtons && "justify-end px-2 py-1",
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
)} )}
style={{ gap: `${gap}px` }} style={{ gap: `${gap}px` }}
> >
@ -409,22 +321,26 @@ export function ResponsiveGridRenderer({
const flexBasis = isFullWidth const flexBasis = isFullWidth
? "100%" ? "100%"
: `calc(${percentWidth}% - ${gap}px)`; : `calc(${percentWidth}% - ${gap}px)`;
const compFlexGrow = shouldFlexGrow(component);
const heightPct = (component.size?.height || 0) / canvasHeight;
const useFlexHeight = heightPct >= 0.8;
return ( return (
<div <div
key={component.id} key={component.id}
data-component-id={component.id} data-component-id={component.id}
data-component-type={typeId} data-component-type={typeId}
className="min-w-0 flex-shrink-0 overflow-hidden" className={cn("min-w-0 overflow-hidden", useFlexHeight && "min-h-0 flex-1")}
style={{ style={{
width: isFullWidth ? "100%" : undefined, width: isFullWidth ? "100%" : undefined,
flexBasis, flexBasis: useFlexHeight ? undefined : flexBasis,
flexGrow: isFullWidth || compFlexGrow ? 1 : 0, flexGrow: 1,
flexShrink: 1,
minWidth: isMobile ? "100%" : undefined, minWidth: isMobile ? "100%" : undefined,
height: component.size?.height minHeight: useFlexHeight ? "300px" : undefined,
height: useFlexHeight ? "100%" : (component.size?.height
? `${component.size.height}px` ? `${component.size.height}px`
: "auto", : "auto"),
}} }}
> >
{renderComponent(component)} {renderComponent(component)}

View File

@ -662,11 +662,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
return null; return null;
} }
// size.width와 size.height를 style.width와 style.height로 변환 const isRuntimeMode = !(props.isDesignMode);
const finalStyle: React.CSSProperties = { const finalStyle: React.CSSProperties = {
...component.style, ...component.style,
width: component.size?.width ? `${component.size.width}px` : component.style?.width, width: isRuntimeMode
height: component.size?.height ? `${component.size.height}px` : component.style?.height, ? "100%"
: (component.size?.width ? `${component.size.width}px` : component.style?.width),
height: isRuntimeMode
? "100%"
: (component.size?.height ? `${component.size.height}px` : component.style?.height),
}; };
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블) // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블)