feat(pop): v4 레이아웃 비율 스케일링 시스템 구현

- PopFlexRenderer에 BASE_VIEWPORT_WIDTH(1024px) 기준 스케일 계산 추가
- 컴포넌트 크기(fixedWidth/Height), gap, padding에 scale 적용
- 뷰어에서 viewportWidth 동적 감지 및 최대 1366px 제한
- 디자인 모드에서는 scale=1 유지, 뷰어에서만 비율 적용
- DndProvider 없는 환경에서 useDrag/useDrop 에러 방지
- v4 레이아웃 뷰어 렌더링 지원 (isPopLayoutV4 체크)
This commit is contained in:
SeongHyun Kim
2026-02-04 14:14:48 +09:00
parent 223f5c0251
commit 6572519092
17 changed files with 5239 additions and 205 deletions

View File

@@ -20,15 +20,18 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext";
import {
PopLayoutDataV3,
PopLayoutDataV4,
PopLayoutModeKey,
ensureV3Layout,
isV3Layout,
isV4Layout,
} from "@/components/pop/designer/types/pop-layout";
import {
PopLayoutRenderer,
hasBaseLayout,
getEffectiveModeLayout,
} from "@/components/pop/designer/renderers";
import { PopFlexRenderer } from "@/components/pop/designer/renderers/PopFlexRenderer";
import {
useResponsiveMode,
useResponsiveModeWithOverride,
@@ -63,12 +66,18 @@ const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => {
return layout && layout.version === "pop-3.0" && layout.layouts && layout.components;
};
// v1/v2 레이아웃인지 확인 (마이그레이션 대상)
// v4.0 레이아웃인지 확인
const isPopLayoutV4 = (layout: any): layout is PopLayoutDataV4 => {
return layout && layout.version === "pop-4.0" && layout.root && layout.components;
};
// v1/v2/v3/v4 레이아웃인지 확인
const isPopLayout = (layout: any): boolean => {
return layout && (
layout.version === "pop-1.0" ||
layout.version === "pop-2.0" ||
layout.version === "pop-3.0"
layout.version === "pop-3.0" ||
layout.version === "pop-4.0"
);
};
@@ -101,6 +110,7 @@ function PopScreenViewPage() {
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<LayoutData | null>(null);
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -108,6 +118,19 @@ function PopScreenViewPage() {
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
const [tableRefreshKey, setTableRefreshKey] = useState(0);
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
useEffect(() => {
const updateViewportWidth = () => {
setViewportWidth(Math.min(window.innerWidth, 1366));
};
updateViewportWidth();
window.addEventListener("resize", updateViewportWidth);
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
// 컴포넌트 초기화
useEffect(() => {
const initComponents = async () => {
@@ -133,10 +156,17 @@ function PopScreenViewPage() {
try {
const popLayout = await screenApi.getLayoutPop(screenId);
if (popLayout && isPopLayout(popLayout)) {
if (popLayout && isPopLayoutV4(popLayout)) {
// v4 레이아웃
setPopLayoutV4(popLayout);
setPopLayoutV3(null);
const componentCount = Object.keys(popLayout.components).length;
console.log(`[POP] v4 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
} else if (popLayout && isPopLayout(popLayout)) {
// v1/v2/v3 → v3로 변환
const v3Layout = ensureV3Layout(popLayout);
setPopLayoutV3(v3Layout);
setPopLayoutV4(null);
const componentCount = Object.keys(v3Layout.components).length;
console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
@@ -151,11 +181,13 @@ function PopScreenViewPage() {
} else {
console.log("[POP] 레이아웃 없음");
setPopLayoutV3(null);
setPopLayoutV4(null);
setLayout(null);
}
} catch (layoutError) {
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
setPopLayoutV3(null);
setPopLayoutV4(null);
setLayout(null);
}
} catch (error) {
@@ -304,8 +336,20 @@ function PopScreenViewPage() {
flexShrink: 0,
} : undefined}
>
{/* POP 레이아웃 v3.0 렌더링 */}
{popLayoutV3 ? (
{/* POP 레이아웃 v4.0 렌더링 */}
{popLayoutV4 ? (
<div
className="mx-auto h-full"
style={{ maxWidth: 1366 }}
>
<PopFlexRenderer
layout={popLayoutV4}
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
isDesignMode={false}
/>
</div>
) : popLayoutV3 ? (
/* POP 레이아웃 v3.0 렌더링 */
<PopLayoutV3Renderer
layout={popLayoutV3}
modeKey={currentModeKey}

View File

@@ -0,0 +1,150 @@
"use client";
import { useState, useCallback } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import {
PopLayoutDataV4,
createEmptyPopLayoutV4,
addComponentToV4Layout,
removeComponentFromV4Layout,
updateComponentInV4Layout,
updateContainerV4,
findContainerV4,
PopComponentType,
PopComponentDefinitionV4,
PopContainerV4,
} from "@/components/pop/designer/types/pop-layout";
import { PopCanvasV4 } from "@/components/pop/designer/PopCanvasV4";
import { PopPanel } from "@/components/pop/designer/panels/PopPanel";
import { ComponentEditorPanelV4 } from "@/components/pop/designer/panels/ComponentEditorPanelV4";
// ========================================
// v4 테스트 페이지
//
// 목적: v4 렌더러, 캔버스, 속성 패널 테스트
// 경로: /pop/test-v4
// ========================================
export default function TestV4Page() {
// 레이아웃 상태
const [layout, setLayout] = useState<PopLayoutDataV4>(() => {
// 초기 테스트 데이터
const initial = createEmptyPopLayoutV4();
return initial;
});
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
// 컴포넌트 ID 카운터
const [idCounter, setIdCounter] = useState(1);
// 선택된 컴포넌트/컨테이너 가져오기
const selectedComponent = selectedComponentId
? layout.components[selectedComponentId]
: null;
const selectedContainer = selectedContainerId
? findContainerV4(layout.root, selectedContainerId)
: null;
// 컴포넌트 드롭
const handleDropComponent = useCallback(
(type: PopComponentType, containerId: string) => {
const componentId = `comp_${idCounter}`;
setIdCounter((prev) => prev + 1);
setLayout((prev) =>
addComponentToV4Layout(prev, componentId, type, containerId, `${type} ${idCounter}`)
);
setSelectedComponentId(componentId);
setSelectedContainerId(null);
},
[idCounter]
);
// 컴포넌트 삭제
const handleDeleteComponent = useCallback((componentId: string) => {
setLayout((prev) => removeComponentFromV4Layout(prev, componentId));
setSelectedComponentId(null);
}, []);
// 컴포넌트 업데이트
const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV4>) => {
setLayout((prev) => updateComponentInV4Layout(prev, componentId, updates));
},
[]
);
// 컨테이너 업데이트
const handleUpdateContainer = useCallback(
(containerId: string, updates: Partial<PopContainerV4>) => {
setLayout((prev) => ({
...prev,
root: updateContainerV4(prev.root, containerId, updates),
}));
},
[]
);
// 선택
const handleSelectComponent = useCallback((id: string | null) => {
setSelectedComponentId(id);
if (id) setSelectedContainerId(null);
}, []);
const handleSelectContainer = useCallback((id: string | null) => {
setSelectedContainerId(id);
if (id) setSelectedComponentId(null);
}, []);
return (
<DndProvider backend={HTML5Backend}>
<div className="flex h-screen w-screen overflow-hidden bg-gray-100">
{/* 왼쪽: 컴포넌트 팔레트 */}
<div className="w-64 shrink-0 border-r bg-white">
<div className="border-b px-4 py-3">
<h2 className="text-sm font-semibold">v4 </h2>
<p className="text-xs text-muted-foreground"> </p>
</div>
<PopPanel />
</div>
{/* 중앙: 캔버스 */}
<div className="flex-1 overflow-hidden">
<PopCanvasV4
layout={layout}
selectedComponentId={selectedComponentId}
selectedContainerId={selectedContainerId}
onSelectComponent={handleSelectComponent}
onSelectContainer={handleSelectContainer}
onDropComponent={handleDropComponent}
onUpdateComponent={handleUpdateComponent}
onUpdateContainer={handleUpdateContainer}
onDeleteComponent={handleDeleteComponent}
/>
</div>
{/* 오른쪽: 속성 패널 */}
<div className="w-72 shrink-0 border-l bg-white">
<ComponentEditorPanelV4
component={selectedComponent}
container={selectedContainer}
onUpdateComponent={
selectedComponentId
? (updates) => handleUpdateComponent(selectedComponentId, updates)
: undefined
}
onUpdateContainer={
selectedContainerId
? (updates) => handleUpdateContainer(selectedContainerId, updates)
: undefined
}
/>
</div>
</div>
</DndProvider>
);
}