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:
parent
223f5c0251
commit
6572519092
|
|
@ -20,15 +20,18 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext";
|
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV3,
|
PopLayoutDataV3,
|
||||||
|
PopLayoutDataV4,
|
||||||
PopLayoutModeKey,
|
PopLayoutModeKey,
|
||||||
ensureV3Layout,
|
ensureV3Layout,
|
||||||
isV3Layout,
|
isV3Layout,
|
||||||
|
isV4Layout,
|
||||||
} from "@/components/pop/designer/types/pop-layout";
|
} from "@/components/pop/designer/types/pop-layout";
|
||||||
import {
|
import {
|
||||||
PopLayoutRenderer,
|
PopLayoutRenderer,
|
||||||
hasBaseLayout,
|
hasBaseLayout,
|
||||||
getEffectiveModeLayout,
|
getEffectiveModeLayout,
|
||||||
} from "@/components/pop/designer/renderers";
|
} from "@/components/pop/designer/renderers";
|
||||||
|
import { PopFlexRenderer } from "@/components/pop/designer/renderers/PopFlexRenderer";
|
||||||
import {
|
import {
|
||||||
useResponsiveMode,
|
useResponsiveMode,
|
||||||
useResponsiveModeWithOverride,
|
useResponsiveModeWithOverride,
|
||||||
|
|
@ -63,12 +66,18 @@ const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => {
|
||||||
return layout && layout.version === "pop-3.0" && layout.layouts && layout.components;
|
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 => {
|
const isPopLayout = (layout: any): boolean => {
|
||||||
return layout && (
|
return layout && (
|
||||||
layout.version === "pop-1.0" ||
|
layout.version === "pop-1.0" ||
|
||||||
layout.version === "pop-2.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 [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||||
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
|
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
|
||||||
|
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -108,6 +118,19 @@ function PopScreenViewPage() {
|
||||||
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
||||||
const [tableRefreshKey, setTableRefreshKey] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
const initComponents = async () => {
|
const initComponents = async () => {
|
||||||
|
|
@ -133,10 +156,17 @@ function PopScreenViewPage() {
|
||||||
try {
|
try {
|
||||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
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로 변환
|
// v1/v2/v3 → v3로 변환
|
||||||
const v3Layout = ensureV3Layout(popLayout);
|
const v3Layout = ensureV3Layout(popLayout);
|
||||||
setPopLayoutV3(v3Layout);
|
setPopLayoutV3(v3Layout);
|
||||||
|
setPopLayoutV4(null);
|
||||||
|
|
||||||
const componentCount = Object.keys(v3Layout.components).length;
|
const componentCount = Object.keys(v3Layout.components).length;
|
||||||
console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
||||||
|
|
@ -151,11 +181,13 @@ function PopScreenViewPage() {
|
||||||
} else {
|
} else {
|
||||||
console.log("[POP] 레이아웃 없음");
|
console.log("[POP] 레이아웃 없음");
|
||||||
setPopLayoutV3(null);
|
setPopLayoutV3(null);
|
||||||
|
setPopLayoutV4(null);
|
||||||
setLayout(null);
|
setLayout(null);
|
||||||
}
|
}
|
||||||
} catch (layoutError) {
|
} catch (layoutError) {
|
||||||
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
||||||
setPopLayoutV3(null);
|
setPopLayoutV3(null);
|
||||||
|
setPopLayoutV4(null);
|
||||||
setLayout(null);
|
setLayout(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -304,8 +336,20 @@ function PopScreenViewPage() {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
>
|
>
|
||||||
{/* POP 레이아웃 v3.0 렌더링 */}
|
{/* POP 레이아웃 v4.0 렌더링 */}
|
||||||
{popLayoutV3 ? (
|
{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
|
<PopLayoutV3Renderer
|
||||||
layout={popLayoutV3}
|
layout={popLayoutV3}
|
||||||
modeKey={currentModeKey}
|
modeKey={currentModeKey}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,334 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState, useEffect } from "react";
|
||||||
|
import { useDrop } from "react-dnd";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopLayoutDataV4,
|
||||||
|
PopContainerV4,
|
||||||
|
PopComponentDefinitionV4,
|
||||||
|
PopComponentType,
|
||||||
|
PopSizeConstraintV4,
|
||||||
|
} from "./types/pop-layout";
|
||||||
|
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
|
||||||
|
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PopFlexRenderer } from "./renderers/PopFlexRenderer";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 프리셋 해상도 (4개 모드)
|
||||||
|
// ========================================
|
||||||
|
const VIEWPORT_PRESETS = [
|
||||||
|
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕", width: 375, height: 667, icon: Smartphone, isLandscape: false },
|
||||||
|
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔", width: 667, height: 375, icon: Smartphone, isLandscape: true },
|
||||||
|
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕", width: 768, height: 1024, icon: Tablet, isLandscape: false },
|
||||||
|
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔", width: 1024, height: 768, icon: Tablet, isLandscape: true },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ViewportPreset = (typeof VIEWPORT_PRESETS)[number]["id"];
|
||||||
|
|
||||||
|
// 기본 프리셋 (태블릿 가로)
|
||||||
|
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
interface PopCanvasV4Props {
|
||||||
|
layout: PopLayoutDataV4;
|
||||||
|
selectedComponentId: string | null;
|
||||||
|
selectedContainerId: string | null;
|
||||||
|
onSelectComponent: (id: string | null) => void;
|
||||||
|
onSelectContainer: (id: string | null) => void;
|
||||||
|
onDropComponent: (type: PopComponentType, containerId: string) => void;
|
||||||
|
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV4>) => void;
|
||||||
|
onUpdateContainer: (containerId: string, updates: Partial<PopContainerV4>) => void;
|
||||||
|
onDeleteComponent: (componentId: string) => void;
|
||||||
|
onResizeComponent?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
||||||
|
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v4 캔버스
|
||||||
|
//
|
||||||
|
// 핵심: 단일 캔버스 + 뷰포트 프리뷰
|
||||||
|
// - 가로/세로 모드 따로 없음
|
||||||
|
// - 다양한 뷰포트 크기로 미리보기
|
||||||
|
// ========================================
|
||||||
|
export function PopCanvasV4({
|
||||||
|
layout,
|
||||||
|
selectedComponentId,
|
||||||
|
selectedContainerId,
|
||||||
|
onSelectComponent,
|
||||||
|
onSelectContainer,
|
||||||
|
onDropComponent,
|
||||||
|
onUpdateComponent,
|
||||||
|
onUpdateContainer,
|
||||||
|
onDeleteComponent,
|
||||||
|
onResizeComponent,
|
||||||
|
onReorderComponent,
|
||||||
|
}: PopCanvasV4Props) {
|
||||||
|
// 줌 상태
|
||||||
|
const [canvasScale, setCanvasScale] = useState(0.8);
|
||||||
|
|
||||||
|
// 현재 뷰포트 프리셋 (기본: 태블릿 가로)
|
||||||
|
const [activeViewport, setActiveViewport] = useState<ViewportPreset>(DEFAULT_PRESET);
|
||||||
|
|
||||||
|
// 커스텀 뷰포트 크기 (슬라이더)
|
||||||
|
const [customWidth, setCustomWidth] = useState(1024);
|
||||||
|
const [customHeight, setCustomHeight] = useState(768);
|
||||||
|
|
||||||
|
// 패닝 상태
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||||
|
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 현재 뷰포트 해상도
|
||||||
|
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === activeViewport)!;
|
||||||
|
const viewportWidth = customWidth;
|
||||||
|
const viewportHeight = customHeight;
|
||||||
|
|
||||||
|
// 줌 컨트롤
|
||||||
|
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
||||||
|
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
||||||
|
const handleZoomFit = () => setCanvasScale(1.0);
|
||||||
|
|
||||||
|
// 뷰포트 프리셋 변경
|
||||||
|
const handleViewportChange = (preset: ViewportPreset) => {
|
||||||
|
setActiveViewport(preset);
|
||||||
|
const presetData = VIEWPORT_PRESETS.find((p) => p.id === preset)!;
|
||||||
|
setCustomWidth(presetData.width);
|
||||||
|
setCustomHeight(presetData.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 슬라이더로 너비 변경 시 높이도 비율에 맞게 조정
|
||||||
|
const handleWidthChange = (newWidth: number) => {
|
||||||
|
setCustomWidth(newWidth);
|
||||||
|
// 현재 프리셋의 가로세로 비율 유지
|
||||||
|
const ratio = currentPreset.height / currentPreset.width;
|
||||||
|
setCustomHeight(Math.round(newWidth * ratio));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 패닝
|
||||||
|
const handlePanStart = (e: React.MouseEvent) => {
|
||||||
|
const isMiddleButton = e.button === 1;
|
||||||
|
const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area");
|
||||||
|
if (isMiddleButton || isSpacePressed || isScrollAreaClick) {
|
||||||
|
setIsPanning(true);
|
||||||
|
setPanStart({ x: e.clientX, y: e.clientY });
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePanMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isPanning || !containerRef.current) return;
|
||||||
|
const deltaX = e.clientX - panStart.x;
|
||||||
|
const deltaY = e.clientY - panStart.y;
|
||||||
|
containerRef.current.scrollLeft -= deltaX;
|
||||||
|
containerRef.current.scrollTop -= deltaY;
|
||||||
|
setPanStart({ x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePanEnd = () => setIsPanning(false);
|
||||||
|
|
||||||
|
// 마우스 휠 줌
|
||||||
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Space 키 감지 (패닝용)
|
||||||
|
// 참고: Delete/Backspace 키는 PopDesigner에서 처리 (히스토리 지원)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true);
|
||||||
|
};
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === "Space") setIsSpacePressed(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
|
};
|
||||||
|
}, [isSpacePressed]);
|
||||||
|
|
||||||
|
// 컴포넌트 드롭
|
||||||
|
const [{ isOver, canDrop }, drop] = useDrop(
|
||||||
|
() => ({
|
||||||
|
accept: DND_ITEM_TYPES.COMPONENT,
|
||||||
|
drop: (item: DragItemComponent) => {
|
||||||
|
// 루트 컨테이너에 추가
|
||||||
|
onDropComponent(item.componentType, "root");
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOver: monitor.isOver(),
|
||||||
|
canDrop: monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[onDropComponent]
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(dropRef);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full flex-col bg-gray-100">
|
||||||
|
{/* 툴바 */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b bg-white px-4 py-2">
|
||||||
|
{/* 뷰포트 프리셋 (4개 모드) */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground mr-2">미리보기:</span>
|
||||||
|
{VIEWPORT_PRESETS.map((preset) => {
|
||||||
|
const Icon = preset.icon;
|
||||||
|
const isActive = activeViewport === preset.id;
|
||||||
|
const isDefault = preset.id === DEFAULT_PRESET;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={preset.id}
|
||||||
|
variant={isActive ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 gap-1 text-xs",
|
||||||
|
isDefault && !isActive && "border-primary/50"
|
||||||
|
)}
|
||||||
|
onClick={() => handleViewportChange(preset.id)}
|
||||||
|
title={preset.label}
|
||||||
|
>
|
||||||
|
<Icon className={cn("h-4 w-4", preset.isLandscape && "rotate-90")} />
|
||||||
|
<span className="hidden lg:inline">{preset.shortLabel}</span>
|
||||||
|
{isDefault && (
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1">(기본)</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 줌 컨트롤 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{Math.round(canvasScale * 100)}%
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut}>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn}>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit}>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 뷰포트 크기 슬라이더 */}
|
||||||
|
<div className="flex shrink-0 items-center gap-4 border-b bg-gray-50 px-4 py-2">
|
||||||
|
<span className="text-xs text-muted-foreground">너비:</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={320}
|
||||||
|
max={1200}
|
||||||
|
value={customWidth}
|
||||||
|
onChange={(e) => handleWidthChange(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono w-24 text-right">
|
||||||
|
{customWidth} x {viewportHeight}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 캔버스 영역 */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"relative flex-1 overflow-auto",
|
||||||
|
isPanning && "cursor-grabbing",
|
||||||
|
isSpacePressed && "cursor-grab"
|
||||||
|
)}
|
||||||
|
onMouseDown={handlePanStart}
|
||||||
|
onMouseMove={handlePanMove}
|
||||||
|
onMouseUp={handlePanEnd}
|
||||||
|
onMouseLeave={handlePanEnd}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="canvas-scroll-area flex items-center justify-center"
|
||||||
|
style={{ padding: "100px", minWidth: "fit-content", minHeight: "fit-content" }}
|
||||||
|
>
|
||||||
|
{/* 디바이스 프레임 */}
|
||||||
|
<div
|
||||||
|
ref={dropRef}
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-xl bg-white shadow-lg transition-all",
|
||||||
|
"ring-2 ring-primary ring-offset-2",
|
||||||
|
isOver && canDrop && "ring-4 ring-green-500 bg-green-50"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: viewportWidth * canvasScale,
|
||||||
|
height: viewportHeight * canvasScale,
|
||||||
|
overflow: "auto", // 컴포넌트가 넘치면 스크롤 가능
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 뷰포트 라벨 */}
|
||||||
|
<div className="absolute -top-6 left-0 text-xs font-medium text-muted-foreground">
|
||||||
|
{currentPreset.label} ({viewportWidth}x{viewportHeight})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flexbox 렌더러 - 최소 높이는 뷰포트 높이, 컨텐츠에 따라 늘어남 */}
|
||||||
|
<div
|
||||||
|
className="origin-top-left"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${canvasScale})`,
|
||||||
|
width: viewportWidth,
|
||||||
|
minHeight: viewportHeight, // height → minHeight로 변경
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopFlexRenderer
|
||||||
|
layout={layout}
|
||||||
|
viewportWidth={viewportWidth}
|
||||||
|
isDesignMode={true}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
onComponentClick={onSelectComponent}
|
||||||
|
onContainerClick={onSelectContainer}
|
||||||
|
onBackgroundClick={() => {
|
||||||
|
onSelectComponent(null);
|
||||||
|
onSelectContainer(null);
|
||||||
|
}}
|
||||||
|
onComponentResize={onResizeComponent}
|
||||||
|
onReorderComponent={onReorderComponent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드롭 안내 (빈 상태) */}
|
||||||
|
{layout.root.children.length === 0 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex items-center justify-center",
|
||||||
|
isOver && canDrop ? "text-green-600" : "text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{isOver && canDrop
|
||||||
|
? "여기에 놓으세요"
|
||||||
|
: "컴포넌트를 드래그하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
왼쪽 패널에서 컴포넌트를 드래그하여 추가
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PopCanvasV4;
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { DndProvider } from "react-dnd";
|
import { DndProvider } from "react-dnd";
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
import { ArrowLeft, Save, Smartphone, Tablet } from "lucide-react";
|
import { ArrowLeft, Save, Smartphone, Tablet, Undo2, Redo2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,26 +14,41 @@ import {
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { PopCanvas } from "./PopCanvas";
|
import { PopCanvas } from "./PopCanvas";
|
||||||
|
import { PopCanvasV4 } from "./PopCanvasV4";
|
||||||
import { PopPanel } from "./panels/PopPanel";
|
import { PopPanel } from "./panels/PopPanel";
|
||||||
|
import { ComponentPaletteV4 } from "./panels/ComponentPaletteV4";
|
||||||
|
import { ComponentEditorPanelV4 } from "./panels/ComponentEditorPanelV4";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV3,
|
PopLayoutDataV3,
|
||||||
|
PopLayoutDataV4,
|
||||||
PopLayoutModeKey,
|
PopLayoutModeKey,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
GridPosition,
|
GridPosition,
|
||||||
PopComponentDefinition,
|
PopComponentDefinition,
|
||||||
|
PopComponentDefinitionV4,
|
||||||
|
PopContainerV4,
|
||||||
|
PopSizeConstraintV4,
|
||||||
createEmptyPopLayoutV3,
|
createEmptyPopLayoutV3,
|
||||||
|
createEmptyPopLayoutV4,
|
||||||
ensureV3Layout,
|
ensureV3Layout,
|
||||||
addComponentToV3Layout,
|
addComponentToV3Layout,
|
||||||
removeComponentFromV3Layout,
|
removeComponentFromV3Layout,
|
||||||
updateComponentPositionInModeV3,
|
updateComponentPositionInModeV3,
|
||||||
|
addComponentToV4Layout,
|
||||||
|
removeComponentFromV4Layout,
|
||||||
|
updateComponentInV4Layout,
|
||||||
|
updateContainerV4,
|
||||||
|
findContainerV4,
|
||||||
isV3Layout,
|
isV3Layout,
|
||||||
|
isV4Layout,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 디바이스 타입
|
// 레이아웃 모드 타입
|
||||||
// ========================================
|
// ========================================
|
||||||
|
type LayoutMode = "v3" | "v4";
|
||||||
type DeviceType = "mobile" | "tablet";
|
type DeviceType = "mobile" | "tablet";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -46,7 +61,9 @@ interface PopDesignerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 메인 컴포넌트 (v3: 섹션 없이 컴포넌트 직접 배치)
|
// 메인 컴포넌트 (v3/v4 통합)
|
||||||
|
// - 새 화면: v4로 시작
|
||||||
|
// - 기존 v3 화면: v3로 로드 (하위 호환)
|
||||||
// ========================================
|
// ========================================
|
||||||
export default function PopDesigner({
|
export default function PopDesigner({
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
|
|
@ -54,27 +71,129 @@ export default function PopDesigner({
|
||||||
onScreenUpdate,
|
onScreenUpdate,
|
||||||
}: PopDesignerProps) {
|
}: PopDesignerProps) {
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레이아웃 상태 (v3)
|
// 레이아웃 모드 (데이터에 따라 자동 결정)
|
||||||
// ========================================
|
// ========================================
|
||||||
const [layout, setLayout] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
|
const [layoutMode, setLayoutMode] = useState<LayoutMode>("v4");
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 레이아웃 상태 (데스크탑 모드와 동일한 방식)
|
||||||
|
// ========================================
|
||||||
|
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
|
||||||
|
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
|
||||||
|
|
||||||
|
// 히스토리 (v4용)
|
||||||
|
const [historyV4, setHistoryV4] = useState<PopLayoutDataV4[]>([]);
|
||||||
|
const [historyIndexV4, setHistoryIndexV4] = useState(-1);
|
||||||
|
|
||||||
|
// 히스토리 (v3용)
|
||||||
|
const [historyV3, setHistoryV3] = useState<PopLayoutDataV3[]>([]);
|
||||||
|
const [historyIndexV3, setHistoryIndexV3] = useState(-1);
|
||||||
|
|
||||||
|
const [idCounter, setIdCounter] = useState(1);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 디바이스/모드 상태
|
// 히스토리 저장 함수
|
||||||
|
// ========================================
|
||||||
|
const saveToHistoryV4 = useCallback((newLayout: PopLayoutDataV4) => {
|
||||||
|
setHistoryV4((prev) => {
|
||||||
|
const newHistory = prev.slice(0, historyIndexV4 + 1);
|
||||||
|
newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사
|
||||||
|
return newHistory.slice(-50); // 최대 50개
|
||||||
|
});
|
||||||
|
setHistoryIndexV4((prev) => Math.min(prev + 1, 49));
|
||||||
|
}, [historyIndexV4]);
|
||||||
|
|
||||||
|
const saveToHistoryV3 = useCallback((newLayout: PopLayoutDataV3) => {
|
||||||
|
setHistoryV3((prev) => {
|
||||||
|
const newHistory = prev.slice(0, historyIndexV3 + 1);
|
||||||
|
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
|
||||||
|
return newHistory.slice(-50);
|
||||||
|
});
|
||||||
|
setHistoryIndexV3((prev) => Math.min(prev + 1, 49));
|
||||||
|
}, [historyIndexV3]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Undo/Redo 함수
|
||||||
|
// ========================================
|
||||||
|
const undoV4 = useCallback(() => {
|
||||||
|
if (historyIndexV4 > 0) {
|
||||||
|
const newIndex = historyIndexV4 - 1;
|
||||||
|
const previousLayout = historyV4[newIndex];
|
||||||
|
if (previousLayout) {
|
||||||
|
setLayoutV4(JSON.parse(JSON.stringify(previousLayout)));
|
||||||
|
setHistoryIndexV4(newIndex);
|
||||||
|
console.log("[Undo V4] 복원됨, index:", newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyIndexV4, historyV4]);
|
||||||
|
|
||||||
|
const redoV4 = useCallback(() => {
|
||||||
|
if (historyIndexV4 < historyV4.length - 1) {
|
||||||
|
const newIndex = historyIndexV4 + 1;
|
||||||
|
const nextLayout = historyV4[newIndex];
|
||||||
|
if (nextLayout) {
|
||||||
|
setLayoutV4(JSON.parse(JSON.stringify(nextLayout)));
|
||||||
|
setHistoryIndexV4(newIndex);
|
||||||
|
console.log("[Redo V4] 복원됨, index:", newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyIndexV4, historyV4]);
|
||||||
|
|
||||||
|
const undoV3 = useCallback(() => {
|
||||||
|
if (historyIndexV3 > 0) {
|
||||||
|
const newIndex = historyIndexV3 - 1;
|
||||||
|
const previousLayout = historyV3[newIndex];
|
||||||
|
if (previousLayout) {
|
||||||
|
setLayoutV3(JSON.parse(JSON.stringify(previousLayout)));
|
||||||
|
setHistoryIndexV3(newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyIndexV3, historyV3]);
|
||||||
|
|
||||||
|
const redoV3 = useCallback(() => {
|
||||||
|
if (historyIndexV3 < historyV3.length - 1) {
|
||||||
|
const newIndex = historyIndexV3 + 1;
|
||||||
|
const nextLayout = historyV3[newIndex];
|
||||||
|
if (nextLayout) {
|
||||||
|
setLayoutV3(JSON.parse(JSON.stringify(nextLayout)));
|
||||||
|
setHistoryIndexV3(newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyIndexV3, historyV3]);
|
||||||
|
|
||||||
|
// 현재 모드의 Undo/Redo
|
||||||
|
const canUndo = layoutMode === "v4" ? historyIndexV4 > 0 : historyIndexV3 > 0;
|
||||||
|
const canRedo = layoutMode === "v4"
|
||||||
|
? historyIndexV4 < historyV4.length - 1
|
||||||
|
: historyIndexV3 < historyV3.length - 1;
|
||||||
|
const handleUndo = layoutMode === "v4" ? undoV4 : undoV3;
|
||||||
|
const handleRedo = layoutMode === "v4" ? redoV4 : redoV3;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v3용 디바이스/모드 상태
|
||||||
// ========================================
|
// ========================================
|
||||||
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
||||||
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 선택 상태 (v3: 섹션 없음, 컴포넌트만)
|
// 선택 상태
|
||||||
// ========================================
|
// ========================================
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 선택된 컴포넌트 정의
|
// 선택된 컴포넌트/컨테이너
|
||||||
const selectedComponent: PopComponentDefinition | null = selectedComponentId
|
const selectedComponentV3: PopComponentDefinition | null = selectedComponentId
|
||||||
? layout.components[selectedComponentId] || null
|
? layoutV3.components[selectedComponentId] || null
|
||||||
|
: null;
|
||||||
|
const selectedComponentV4: PopComponentDefinitionV4 | null = selectedComponentId
|
||||||
|
? layoutV4.components[selectedComponentId] || null
|
||||||
|
: null;
|
||||||
|
const selectedContainer: PopContainerV4 | null = selectedContainerId
|
||||||
|
? findContainerV4(layoutV4.root, selectedContainerId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -88,25 +207,45 @@ export default function PopDesigner({
|
||||||
try {
|
try {
|
||||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
if (loadedLayout) {
|
// 유효한 레이아웃인지 확인:
|
||||||
// v1, v2, v3 → v3로 변환
|
// 1. version 필드 필수
|
||||||
|
// 2. 컴포넌트가 있어야 함 (빈 레이아웃은 새 화면 취급)
|
||||||
|
const hasValidLayout = loadedLayout && loadedLayout.version;
|
||||||
|
const hasComponents = loadedLayout?.components && Object.keys(loadedLayout.components).length > 0;
|
||||||
|
|
||||||
|
if (hasValidLayout && hasComponents) {
|
||||||
|
if (isV4Layout(loadedLayout)) {
|
||||||
|
// v4 레이아웃
|
||||||
|
setLayoutV4(loadedLayout);
|
||||||
|
setHistoryV4([loadedLayout]);
|
||||||
|
setHistoryIndexV4(0);
|
||||||
|
setLayoutMode("v4");
|
||||||
|
console.log(`POP v4 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`);
|
||||||
|
} else {
|
||||||
|
// v1/v2/v3 → v3로 변환
|
||||||
const v3Layout = ensureV3Layout(loadedLayout);
|
const v3Layout = ensureV3Layout(loadedLayout);
|
||||||
setLayout(v3Layout);
|
setLayoutV3(v3Layout);
|
||||||
|
setHistoryV3([v3Layout]);
|
||||||
const componentCount = Object.keys(v3Layout.components).length;
|
setHistoryIndexV3(0);
|
||||||
console.log(`POP v3 레이아웃 로드 성공: ${componentCount}개 컴포넌트`);
|
setLayoutMode("v3");
|
||||||
|
console.log(`POP v3 레이아웃 로드: ${Object.keys(v3Layout.components).length}개 컴포넌트`);
|
||||||
if (!isV3Layout(loadedLayout)) {
|
|
||||||
console.log("v1/v2 → v3 자동 마이그레이션 완료");
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("POP 레이아웃 없음, 빈 v3 레이아웃 생성");
|
// 새 화면 또는 빈 레이아웃 → v4로 시작
|
||||||
setLayout(createEmptyPopLayoutV3());
|
const emptyLayout = createEmptyPopLayoutV4();
|
||||||
|
setLayoutV4(emptyLayout);
|
||||||
|
setHistoryV4([emptyLayout]);
|
||||||
|
setHistoryIndexV4(0);
|
||||||
|
setLayoutMode("v4");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
console.error("레이아웃 로드 실패:", error);
|
||||||
toast.error("레이아웃을 불러오는데 실패했습니다");
|
toast.error("레이아웃을 불러오는데 실패했습니다");
|
||||||
setLayout(createEmptyPopLayoutV3());
|
const emptyLayout = createEmptyPopLayoutV4();
|
||||||
|
setLayoutV4(emptyLayout);
|
||||||
|
setHistoryV4([emptyLayout]);
|
||||||
|
setHistoryIndexV4(0);
|
||||||
|
setLayoutMode("v4");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +262,8 @@ export default function PopDesigner({
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await screenApi.saveLayoutPop(selectedScreen.screenId, layout);
|
const layoutToSave = layoutMode === "v3" ? layoutV3 : layoutV4;
|
||||||
|
await screenApi.saveLayoutPop(selectedScreen.screenId, layoutToSave);
|
||||||
toast.success("저장되었습니다");
|
toast.success("저장되었습니다");
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -132,73 +272,180 @@ export default function PopDesigner({
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [selectedScreen?.screenId, layout]);
|
}, [selectedScreen?.screenId, layoutMode, layoutV3, layoutV4]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 컴포넌트 추가 (4모드 동기화)
|
// v3: 컴포넌트 핸들러
|
||||||
// ========================================
|
// ========================================
|
||||||
const handleDropComponent = useCallback(
|
const handleDropComponentV3 = useCallback(
|
||||||
(type: PopComponentType, gridPosition: GridPosition) => {
|
(type: PopComponentType, gridPosition: GridPosition) => {
|
||||||
const newId = `${type}-${Date.now()}`;
|
const newId = `${type}-${Date.now()}`;
|
||||||
setLayout((prev) => addComponentToV3Layout(prev, newId, type, gridPosition));
|
const newLayout = addComponentToV3Layout(layoutV3, newId, type, gridPosition);
|
||||||
|
setLayoutV3(newLayout);
|
||||||
|
saveToHistoryV3(newLayout);
|
||||||
setSelectedComponentId(newId);
|
setSelectedComponentId(newId);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[]
|
[layoutV3, saveToHistoryV3]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================================
|
const handleUpdateComponentDefinitionV3 = useCallback(
|
||||||
// 컴포넌트 정의 업데이트
|
|
||||||
// ========================================
|
|
||||||
const handleUpdateComponentDefinition = useCallback(
|
|
||||||
(componentId: string, updates: Partial<PopComponentDefinition>) => {
|
(componentId: string, updates: Partial<PopComponentDefinition>) => {
|
||||||
setLayout((prev) => ({
|
const newLayout = {
|
||||||
...prev,
|
...layoutV3,
|
||||||
components: {
|
components: {
|
||||||
...prev.components,
|
...layoutV3.components,
|
||||||
[componentId]: {
|
[componentId]: { ...layoutV3.components[componentId], ...updates },
|
||||||
...prev.components[componentId],
|
|
||||||
...updates,
|
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
}));
|
setLayoutV3(newLayout);
|
||||||
|
saveToHistoryV3(newLayout);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[]
|
[layoutV3, saveToHistoryV3]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================================
|
const handleUpdateComponentPositionV3 = useCallback(
|
||||||
// 컴포넌트 위치 업데이트 (현재 모드만)
|
|
||||||
// ========================================
|
|
||||||
const handleUpdateComponentPosition = useCallback(
|
|
||||||
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
||||||
const targetMode = modeKey || activeModeKey;
|
const targetMode = modeKey || activeModeKey;
|
||||||
setLayout((prev) => updateComponentPositionInModeV3(prev, targetMode, componentId, position));
|
const newLayout = updateComponentPositionInModeV3(layoutV3, targetMode, componentId, position);
|
||||||
|
setLayoutV3(newLayout);
|
||||||
|
saveToHistoryV3(newLayout);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[activeModeKey]
|
[layoutV3, activeModeKey, saveToHistoryV3]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteComponentV3 = useCallback((componentId: string) => {
|
||||||
|
const newLayout = removeComponentFromV3Layout(layoutV3, componentId);
|
||||||
|
setLayoutV3(newLayout);
|
||||||
|
saveToHistoryV3(newLayout);
|
||||||
|
setSelectedComponentId(null);
|
||||||
|
setHasChanges(true);
|
||||||
|
}, [layoutV3, saveToHistoryV3]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v4: 컴포넌트 핸들러
|
||||||
|
// ========================================
|
||||||
|
const handleDropComponentV4 = useCallback(
|
||||||
|
(type: PopComponentType, containerId: string) => {
|
||||||
|
const componentId = `comp_${idCounter}`;
|
||||||
|
setIdCounter((prev) => prev + 1);
|
||||||
|
const newLayout = addComponentToV4Layout(layoutV4, componentId, type, containerId, `${type} ${idCounter}`);
|
||||||
|
setLayoutV4(newLayout);
|
||||||
|
saveToHistoryV4(newLayout);
|
||||||
|
setSelectedComponentId(componentId);
|
||||||
|
setSelectedContainerId(null);
|
||||||
|
setHasChanges(true);
|
||||||
|
console.log("[V4] 컴포넌트 추가, 히스토리 저장됨");
|
||||||
|
},
|
||||||
|
[idCounter, layoutV4, saveToHistoryV4]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateComponentV4 = useCallback(
|
||||||
|
(componentId: string, updates: Partial<PopComponentDefinitionV4>) => {
|
||||||
|
const newLayout = updateComponentInV4Layout(layoutV4, componentId, updates);
|
||||||
|
setLayoutV4(newLayout);
|
||||||
|
saveToHistoryV4(newLayout);
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[layoutV4, saveToHistoryV4]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateContainerV4 = useCallback(
|
||||||
|
(containerId: string, updates: Partial<PopContainerV4>) => {
|
||||||
|
const newLayout = {
|
||||||
|
...layoutV4,
|
||||||
|
root: updateContainerV4(layoutV4.root, containerId, updates),
|
||||||
|
};
|
||||||
|
setLayoutV4(newLayout);
|
||||||
|
saveToHistoryV4(newLayout);
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[layoutV4, saveToHistoryV4]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteComponentV4 = useCallback((componentId: string) => {
|
||||||
|
const newLayout = removeComponentFromV4Layout(layoutV4, componentId);
|
||||||
|
setLayoutV4(newLayout);
|
||||||
|
saveToHistoryV4(newLayout);
|
||||||
|
setSelectedComponentId(null);
|
||||||
|
setHasChanges(true);
|
||||||
|
console.log("[V4] 컴포넌트 삭제, 히스토리 저장됨");
|
||||||
|
}, [layoutV4, saveToHistoryV4]);
|
||||||
|
|
||||||
|
// v4: 컴포넌트 크기 조정 (드래그) - 리사이즈 중에는 히스토리 저장 안 함
|
||||||
|
// 리사이즈 완료 시 별도로 저장해야 함 (TODO: 드래그 종료 시 저장)
|
||||||
|
const handleResizeComponentV4 = useCallback(
|
||||||
|
(componentId: string, sizeUpdates: Partial<PopSizeConstraintV4>) => {
|
||||||
|
const existingComponent = layoutV4.components[componentId];
|
||||||
|
if (!existingComponent) return;
|
||||||
|
|
||||||
|
const newLayout = {
|
||||||
|
...layoutV4,
|
||||||
|
components: {
|
||||||
|
...layoutV4.components,
|
||||||
|
[componentId]: {
|
||||||
|
...existingComponent,
|
||||||
|
size: {
|
||||||
|
...existingComponent.size,
|
||||||
|
...sizeUpdates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setLayoutV4(newLayout);
|
||||||
|
// 리사이즈 중에는 히스토리 저장 안 함 (너무 많아짐)
|
||||||
|
// saveToHistoryV4(newLayout);
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[layoutV4]
|
||||||
|
);
|
||||||
|
|
||||||
|
// v4: 컴포넌트 순서 변경 (드래그 앤 드롭)
|
||||||
|
const handleReorderComponentV4 = useCallback(
|
||||||
|
(containerId: string, fromIndex: number, toIndex: number) => {
|
||||||
|
// 컨테이너 찾기 (재귀)
|
||||||
|
const reorderInContainer = (container: PopContainerV4): PopContainerV4 => {
|
||||||
|
if (container.id === containerId) {
|
||||||
|
const newChildren = [...container.children];
|
||||||
|
const [movedItem] = newChildren.splice(fromIndex, 1);
|
||||||
|
newChildren.splice(toIndex, 0, movedItem);
|
||||||
|
return { ...container, children: newChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자식 컨테이너에서도 찾기
|
||||||
|
return {
|
||||||
|
...container,
|
||||||
|
children: container.children.map(child => {
|
||||||
|
if (typeof child === "object") {
|
||||||
|
return reorderInContainer(child);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLayout = {
|
||||||
|
...layoutV4,
|
||||||
|
root: reorderInContainer(layoutV4.root),
|
||||||
|
};
|
||||||
|
setLayoutV4(newLayout);
|
||||||
|
saveToHistoryV4(newLayout);
|
||||||
|
setHasChanges(true);
|
||||||
|
console.log("[V4] 컴포넌트 순서 변경", { containerId, fromIndex, toIndex });
|
||||||
|
},
|
||||||
|
[layoutV4, saveToHistoryV4]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 컴포넌트 삭제 (4모드 동기화)
|
// v3: 디바이스/모드 전환
|
||||||
// ========================================
|
|
||||||
const handleDeleteComponent = useCallback((componentId: string) => {
|
|
||||||
setLayout((prev) => removeComponentFromV3Layout(prev, componentId));
|
|
||||||
setSelectedComponentId(null);
|
|
||||||
setHasChanges(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 디바이스 전환
|
|
||||||
// ========================================
|
// ========================================
|
||||||
const handleDeviceChange = useCallback((device: DeviceType) => {
|
const handleDeviceChange = useCallback((device: DeviceType) => {
|
||||||
setActiveDevice(device);
|
setActiveDevice(device);
|
||||||
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
|
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 모드 키 전환
|
|
||||||
// ========================================
|
|
||||||
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
|
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
|
||||||
setActiveModeKey(modeKey);
|
setActiveModeKey(modeKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -217,33 +464,72 @@ export default function PopDesigner({
|
||||||
}, [hasChanges, onBackToList]);
|
}, [hasChanges, onBackToList]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Delete 키 삭제 기능
|
// 단축키 처리 (Delete, Undo, Redo)
|
||||||
// ========================================
|
// ========================================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
||||||
target.tagName === "INPUT" ||
|
|
||||||
target.tagName === "TEXTAREA" ||
|
|
||||||
target.isContentEditable
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
// Delete / Backspace: 컴포넌트 삭제
|
||||||
if (e.key === "Delete" || e.key === "Backspace") {
|
if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (selectedComponentId) {
|
if (selectedComponentId) {
|
||||||
handleDeleteComponent(selectedComponentId);
|
layoutMode === "v3" ? handleDeleteComponentV3(selectedComponentId) : handleDeleteComponentV4(selectedComponentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+Z / Cmd+Z: Undo (Shift 안 눌림)
|
||||||
|
if (isCtrlOrCmd && key === "z" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log("Undo 시도:", { canUndo, layoutMode });
|
||||||
|
if (canUndo) {
|
||||||
|
handleUndo();
|
||||||
|
setHasChanges(true);
|
||||||
|
toast.success("실행 취소됨");
|
||||||
|
} else {
|
||||||
|
toast.info("실행 취소할 내용이 없습니다");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+Z / Cmd+Shift+Z: Redo
|
||||||
|
if (isCtrlOrCmd && key === "z" && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log("Redo 시도:", { canRedo, layoutMode });
|
||||||
|
if (canRedo) {
|
||||||
|
handleRedo();
|
||||||
|
setHasChanges(true);
|
||||||
|
toast.success("다시 실행됨");
|
||||||
|
} else {
|
||||||
|
toast.info("다시 실행할 내용이 없습니다");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Y / Cmd+Y: Redo (대체)
|
||||||
|
if (isCtrlOrCmd && key === "y") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canRedo) {
|
||||||
|
handleRedo();
|
||||||
|
setHasChanges(true);
|
||||||
|
toast.success("다시 실행됨");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [selectedComponentId, handleDeleteComponent]);
|
}, [selectedComponentId, layoutMode, handleDeleteComponentV3, handleDeleteComponentV4, canUndo, canRedo, handleUndo, handleRedo]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 로딩 상태
|
// 로딩
|
||||||
// ========================================
|
// ========================================
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -270,17 +556,17 @@ export default function PopDesigner({
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{selectedScreen?.screenName || "POP 화면"}
|
{selectedScreen?.screenName || "POP 화면"}
|
||||||
</span>
|
</span>
|
||||||
{hasChanges && (
|
{hasChanges && <span className="text-xs text-orange-500">*변경됨</span>}
|
||||||
<span className="text-xs text-orange-500">*변경됨</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: 디바이스 전환 */}
|
{/* 중앙: 레이아웃 버전 + v3 디바이스 전환 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<Tabs
|
<span className="text-muted-foreground text-xs">
|
||||||
value={activeDevice}
|
{layoutMode === "v4" ? "자동 레이아웃 (v4)" : "4모드 레이아웃 (v3)"}
|
||||||
onValueChange={(v) => handleDeviceChange(v as DeviceType)}
|
</span>
|
||||||
>
|
|
||||||
|
{layoutMode === "v3" && (
|
||||||
|
<Tabs value={activeDevice} onValueChange={(v) => handleDeviceChange(v as DeviceType)}>
|
||||||
<TabsList className="h-8">
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
|
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
|
||||||
<Tablet className="mr-1 h-3.5 w-3.5" />
|
<Tablet className="mr-1 h-3.5 w-3.5" />
|
||||||
|
|
@ -292,15 +578,45 @@ export default function PopDesigner({
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: 저장 */}
|
{/* 오른쪽: Undo/Redo + 저장 */}
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Undo/Redo 버튼 */}
|
||||||
|
<div className="flex items-center gap-1 border-r pr-2 mr-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={handleSave}
|
size="icon"
|
||||||
disabled={isSaving || !hasChanges}
|
className="h-8 w-8"
|
||||||
|
onClick={() => {
|
||||||
|
handleUndo();
|
||||||
|
setHasChanges(true);
|
||||||
|
toast.success("실행 취소됨");
|
||||||
|
}}
|
||||||
|
disabled={!canUndo}
|
||||||
|
title="실행 취소 (Ctrl+Z)"
|
||||||
>
|
>
|
||||||
|
<Undo2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => {
|
||||||
|
handleRedo();
|
||||||
|
setHasChanges(true);
|
||||||
|
toast.success("다시 실행됨");
|
||||||
|
}}
|
||||||
|
disabled={!canRedo}
|
||||||
|
title="다시 실행 (Ctrl+Shift+Z)"
|
||||||
|
>
|
||||||
|
<Redo2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 저장 버튼 */}
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasChanges}>
|
||||||
<Save className="mr-1 h-4 w-4" />
|
<Save className="mr-1 h-4 w-4" />
|
||||||
{isSaving ? "저장 중..." : "저장"}
|
{isSaving ? "저장 중..." : "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -309,40 +625,78 @@ export default function PopDesigner({
|
||||||
|
|
||||||
{/* 메인 영역 */}
|
{/* 메인 영역 */}
|
||||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||||
{/* 왼쪽: 패널 */}
|
{/* 왼쪽: 컴포넌트 패널 */}
|
||||||
<ResizablePanel
|
<ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="border-r">
|
||||||
defaultSize={20}
|
{layoutMode === "v3" ? (
|
||||||
minSize={15}
|
|
||||||
maxSize={30}
|
|
||||||
className="border-r"
|
|
||||||
>
|
|
||||||
<PopPanel
|
<PopPanel
|
||||||
layout={layout}
|
layout={layoutV3}
|
||||||
activeModeKey={activeModeKey}
|
activeModeKey={activeModeKey}
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
selectedComponent={selectedComponent}
|
selectedComponent={selectedComponentV3}
|
||||||
onUpdateComponentDefinition={handleUpdateComponentDefinition}
|
onUpdateComponentDefinition={handleUpdateComponentDefinitionV3}
|
||||||
onDeleteComponent={handleDeleteComponent}
|
onDeleteComponent={handleDeleteComponentV3}
|
||||||
activeDevice={activeDevice}
|
activeDevice={activeDevice}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ComponentPaletteV4 />
|
||||||
|
)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
{/* 오른쪽: 캔버스 */}
|
{/* 중앙: 캔버스 */}
|
||||||
<ResizablePanel defaultSize={80}>
|
<ResizablePanel defaultSize={layoutMode === "v3" ? 80 : 60}>
|
||||||
|
{layoutMode === "v3" ? (
|
||||||
<PopCanvas
|
<PopCanvas
|
||||||
layout={layout}
|
layout={layoutV3}
|
||||||
activeDevice={activeDevice}
|
activeDevice={activeDevice}
|
||||||
activeModeKey={activeModeKey}
|
activeModeKey={activeModeKey}
|
||||||
onModeKeyChange={handleModeKeyChange}
|
onModeKeyChange={handleModeKeyChange}
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
onSelectComponent={setSelectedComponentId}
|
onSelectComponent={setSelectedComponentId}
|
||||||
onUpdateComponentPosition={handleUpdateComponentPosition}
|
onUpdateComponentPosition={handleUpdateComponentPositionV3}
|
||||||
onDropComponent={handleDropComponent}
|
onDropComponent={handleDropComponentV3}
|
||||||
onDeleteComponent={handleDeleteComponent}
|
onDeleteComponent={handleDeleteComponentV3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PopCanvasV4
|
||||||
|
layout={layoutV4}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
selectedContainerId={selectedContainerId}
|
||||||
|
onSelectComponent={setSelectedComponentId}
|
||||||
|
onSelectContainer={setSelectedContainerId}
|
||||||
|
onDropComponent={handleDropComponentV4}
|
||||||
|
onUpdateComponent={handleUpdateComponentV4}
|
||||||
|
onUpdateContainer={handleUpdateContainerV4}
|
||||||
|
onDeleteComponent={handleDeleteComponentV4}
|
||||||
|
onResizeComponent={handleResizeComponentV4}
|
||||||
|
onReorderComponent={handleReorderComponentV4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
{/* 오른쪽: 속성 패널 (v4만) */}
|
||||||
|
{layoutMode === "v4" && (
|
||||||
|
<>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
|
||||||
|
<ComponentEditorPanelV4
|
||||||
|
component={selectedComponentV4}
|
||||||
|
container={selectedContainer}
|
||||||
|
onUpdateComponent={
|
||||||
|
selectedComponentId
|
||||||
|
? (updates) => handleUpdateComponentV4(selectedComponentId, updates)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onUpdateContainer={
|
||||||
|
selectedContainerId
|
||||||
|
? (updates) => handleUpdateContainerV4(selectedContainerId, updates)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,609 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopComponentDefinitionV4,
|
||||||
|
PopSizeConstraintV4,
|
||||||
|
PopContainerV4,
|
||||||
|
PopComponentType,
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Database,
|
||||||
|
Link2,
|
||||||
|
MoveHorizontal,
|
||||||
|
MoveVertical,
|
||||||
|
Square,
|
||||||
|
Maximize2,
|
||||||
|
AlignCenter,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ComponentEditorPanelV4Props {
|
||||||
|
/** 선택된 컴포넌트 */
|
||||||
|
component: PopComponentDefinitionV4 | null;
|
||||||
|
/** 선택된 컨테이너 */
|
||||||
|
container: PopContainerV4 | null;
|
||||||
|
/** 컴포넌트 업데이트 */
|
||||||
|
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
||||||
|
/** 컨테이너 업데이트 */
|
||||||
|
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
|
||||||
|
/** 추가 className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 타입별 라벨
|
||||||
|
// ========================================
|
||||||
|
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
|
"pop-field": "필드",
|
||||||
|
"pop-button": "버튼",
|
||||||
|
"pop-list": "리스트",
|
||||||
|
"pop-indicator": "인디케이터",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
|
"pop-numpad": "숫자패드",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v4 컴포넌트 편집 패널
|
||||||
|
//
|
||||||
|
// 핵심:
|
||||||
|
// - 크기 제약 편집 (fixed/fill/hug)
|
||||||
|
// - 반응형 숨김 설정
|
||||||
|
// - 개별 정렬 설정
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export function ComponentEditorPanelV4({
|
||||||
|
component,
|
||||||
|
container,
|
||||||
|
onUpdateComponent,
|
||||||
|
onUpdateContainer,
|
||||||
|
className,
|
||||||
|
}: ComponentEditorPanelV4Props) {
|
||||||
|
// 아무것도 선택되지 않은 경우
|
||||||
|
if (!component && !container) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
|
<div className="border-b px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium">속성</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
||||||
|
컴포넌트 또는 컨테이너를 선택하세요
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컨테이너가 선택된 경우
|
||||||
|
if (container) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
|
<div className="border-b px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium">컨테이너 설정</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{container.id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
<ContainerSettingsForm
|
||||||
|
container={container}
|
||||||
|
onUpdate={onUpdateContainer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트가 선택된 경우
|
||||||
|
return (
|
||||||
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="border-b px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{component!.label || COMPONENT_TYPE_LABELS[component!.type]}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{component!.type}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 컨텐츠 */}
|
||||||
|
<Tabs defaultValue="size" className="flex-1">
|
||||||
|
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
||||||
|
<TabsTrigger value="size" className="gap-1 text-xs">
|
||||||
|
<Maximize2 className="h-3 w-3" />
|
||||||
|
크기
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings" className="gap-1 text-xs">
|
||||||
|
<Settings className="h-3 w-3" />
|
||||||
|
설정
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="data" className="gap-1 text-xs">
|
||||||
|
<Database className="h-3 w-3" />
|
||||||
|
데이터
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 크기 제약 탭 */}
|
||||||
|
<TabsContent value="size" className="flex-1 overflow-auto p-4">
|
||||||
|
<SizeConstraintForm
|
||||||
|
component={component!}
|
||||||
|
onUpdate={onUpdateComponent}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 기본 설정 탭 */}
|
||||||
|
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
||||||
|
<ComponentSettingsForm
|
||||||
|
component={component!}
|
||||||
|
onUpdate={onUpdateComponent}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 데이터 바인딩 탭 */}
|
||||||
|
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
||||||
|
<DataBindingPlaceholder />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 크기 제약 폼
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface SizeConstraintFormProps {
|
||||||
|
component: PopComponentDefinitionV4;
|
||||||
|
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) {
|
||||||
|
const { size } = component;
|
||||||
|
|
||||||
|
const handleSizeChange = (
|
||||||
|
field: keyof PopSizeConstraintV4,
|
||||||
|
value: string | number | undefined
|
||||||
|
) => {
|
||||||
|
onUpdate?.({
|
||||||
|
size: {
|
||||||
|
...size,
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 너비 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1">
|
||||||
|
<MoveHorizontal className="h-3 w-3" />
|
||||||
|
너비
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<SizeButton
|
||||||
|
active={size.width === "fixed"}
|
||||||
|
onClick={() => handleSizeChange("width", "fixed")}
|
||||||
|
label="고정"
|
||||||
|
description="px"
|
||||||
|
/>
|
||||||
|
<SizeButton
|
||||||
|
active={size.width === "fill"}
|
||||||
|
onClick={() => handleSizeChange("width", "fill")}
|
||||||
|
label="채움"
|
||||||
|
description="flex"
|
||||||
|
/>
|
||||||
|
<SizeButton
|
||||||
|
active={size.width === "hug"}
|
||||||
|
onClick={() => handleSizeChange("width", "hug")}
|
||||||
|
label="맞춤"
|
||||||
|
description="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 고정 너비 입력 */}
|
||||||
|
{size.width === "fixed" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
value={size.fixedWidth || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleSizeChange(
|
||||||
|
"fixedWidth",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="너비"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">px</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 채움일 때 최소/최대 */}
|
||||||
|
{size.width === "fill" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
value={size.minWidth || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleSizeChange(
|
||||||
|
"minWidth",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="최소"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">~</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
value={size.maxWidth || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleSizeChange(
|
||||||
|
"maxWidth",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="최대"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">px</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 높이 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1">
|
||||||
|
<MoveVertical className="h-3 w-3" />
|
||||||
|
높이
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<SizeButton
|
||||||
|
active={size.height === "fixed"}
|
||||||
|
onClick={() => handleSizeChange("height", "fixed")}
|
||||||
|
label="고정"
|
||||||
|
description="px"
|
||||||
|
/>
|
||||||
|
<SizeButton
|
||||||
|
active={size.height === "fill"}
|
||||||
|
onClick={() => handleSizeChange("height", "fill")}
|
||||||
|
label="채움"
|
||||||
|
description="flex"
|
||||||
|
/>
|
||||||
|
<SizeButton
|
||||||
|
active={size.height === "hug"}
|
||||||
|
onClick={() => handleSizeChange("height", "hug")}
|
||||||
|
label="맞춤"
|
||||||
|
description="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 고정 높이 입력 */}
|
||||||
|
{size.height === "fixed" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
value={size.fixedHeight || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleSizeChange(
|
||||||
|
"fixedHeight",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="높이"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">px</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 채움일 때 최소 */}
|
||||||
|
{size.height === "fill" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
value={size.minHeight || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleSizeChange(
|
||||||
|
"minHeight",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="최소 높이"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">px</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 개별 정렬 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1">
|
||||||
|
<AlignCenter className="h-3 w-3" />
|
||||||
|
개별 정렬
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={component.alignSelf || "none"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onUpdate?.({
|
||||||
|
alignSelf: value === "none" ? undefined : (value as any),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컨테이너 설정 따름" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">컨테이너 설정 따름</SelectItem>
|
||||||
|
<SelectItem value="start">시작</SelectItem>
|
||||||
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
|
<SelectItem value="end">끝</SelectItem>
|
||||||
|
<SelectItem value="stretch">늘이기</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 반응형 숨김 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">반응형 숨김</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
value={component.hideBelow || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate?.({
|
||||||
|
hideBelow: e.target.value ? Number(e.target.value) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="없음"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">px 이하에서 숨김</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 크기 버튼 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface SizeButtonProps {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SizeButton({ active, onClick, label, description }: SizeButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex flex-col items-center gap-0.5 rounded-md border p-2 text-xs transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{description}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컨테이너 설정 폼
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ContainerSettingsFormProps {
|
||||||
|
container: PopContainerV4;
|
||||||
|
onUpdate?: (updates: Partial<PopContainerV4>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerSettingsForm({
|
||||||
|
container,
|
||||||
|
onUpdate,
|
||||||
|
}: ContainerSettingsFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 방향 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">방향</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant={container.direction === "horizontal" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs"
|
||||||
|
onClick={() => onUpdate?.({ direction: "horizontal" })}
|
||||||
|
>
|
||||||
|
가로
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={container.direction === "vertical" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs"
|
||||||
|
onClick={() => onUpdate?.({ direction: "vertical" })}
|
||||||
|
>
|
||||||
|
세로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 줄바꿈 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">줄바꿈</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant={container.wrap ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs"
|
||||||
|
onClick={() => onUpdate?.({ wrap: true })}
|
||||||
|
>
|
||||||
|
허용
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={!container.wrap ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs"
|
||||||
|
onClick={() => onUpdate?.({ wrap: false })}
|
||||||
|
>
|
||||||
|
금지
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 간격 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">간격 (gap)</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
value={container.gap}
|
||||||
|
onChange={(e) => onUpdate?.({ gap: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 패딩 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">패딩</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
value={container.padding || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate?.({ padding: Number(e.target.value) || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정렬 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">교차축 정렬</Label>
|
||||||
|
<Select
|
||||||
|
value={container.alignItems}
|
||||||
|
onValueChange={(value) => onUpdate?.({ alignItems: value as any })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="start">시작</SelectItem>
|
||||||
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
|
<SelectItem value="end">끝</SelectItem>
|
||||||
|
<SelectItem value="stretch">늘이기</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">주축 정렬</Label>
|
||||||
|
<Select
|
||||||
|
value={container.justifyContent}
|
||||||
|
onValueChange={(value) => onUpdate?.({ justifyContent: value as any })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="start">시작</SelectItem>
|
||||||
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
|
<SelectItem value="end">끝</SelectItem>
|
||||||
|
<SelectItem value="space-between">균등 배치</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 설정 폼
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ComponentSettingsFormProps {
|
||||||
|
component: PopComponentDefinitionV4;
|
||||||
|
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentSettingsForm({
|
||||||
|
component,
|
||||||
|
onUpdate,
|
||||||
|
}: ComponentSettingsFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 라벨 입력 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium">라벨</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={component.label || ""}
|
||||||
|
onChange={(e) => onUpdate?.({ label: e.target.value })}
|
||||||
|
placeholder="컴포넌트 라벨"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타입별 설정 (TODO: 상세 구현) */}
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
{COMPONENT_TYPE_LABELS[component.type]} 상세 설정
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-center text-xs text-muted-foreground">
|
||||||
|
(추후 구현 예정)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 데이터 바인딩 플레이스홀더
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function DataBindingPlaceholder() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Database className="h-8 w-8 text-gray-400" />
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
데이터 바인딩 설정
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
테이블 선택 - 칼럼 선택 - 조인 설정
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-center text-xs text-gray-400">
|
||||||
|
(추후 구현 예정)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ComponentEditorPanelV4;
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDrag } from "react-dnd";
|
||||||
|
import {
|
||||||
|
Type,
|
||||||
|
MousePointer,
|
||||||
|
List,
|
||||||
|
Activity,
|
||||||
|
ScanLine,
|
||||||
|
Calculator,
|
||||||
|
GripVertical,
|
||||||
|
Space,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PopComponentType } from "../types/pop-layout";
|
||||||
|
import { DND_ITEM_TYPES, DragItemComponent } from "./PopPanel";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 팔레트 정의
|
||||||
|
// ========================================
|
||||||
|
const COMPONENT_PALETTE: {
|
||||||
|
type: PopComponentType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
type: "pop-field",
|
||||||
|
label: "필드",
|
||||||
|
icon: Type,
|
||||||
|
description: "텍스트, 숫자 등 데이터 입력",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-button",
|
||||||
|
label: "버튼",
|
||||||
|
icon: MousePointer,
|
||||||
|
description: "저장, 삭제 등 액션 실행",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-list",
|
||||||
|
label: "리스트",
|
||||||
|
icon: List,
|
||||||
|
description: "데이터 목록 (카드 템플릿 지원)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-indicator",
|
||||||
|
label: "인디케이터",
|
||||||
|
icon: Activity,
|
||||||
|
description: "KPI, 상태 표시",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-scanner",
|
||||||
|
label: "스캐너",
|
||||||
|
icon: ScanLine,
|
||||||
|
description: "바코드/QR 스캔",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-numpad",
|
||||||
|
label: "숫자패드",
|
||||||
|
icon: Calculator,
|
||||||
|
description: "숫자 입력 전용",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-spacer",
|
||||||
|
label: "스페이서",
|
||||||
|
icon: Space,
|
||||||
|
description: "빈 공간 (정렬용)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v4 컴포넌트 팔레트
|
||||||
|
// ========================================
|
||||||
|
export function ComponentPaletteV4() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="border-b p-3">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
편집 중: v4 (자동 반응형)
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
규칙 기반 레이아웃
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컴포넌트 목록 */}
|
||||||
|
<div className="flex-1 overflow-auto p-3">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
컴포넌트
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{COMPONENT_PALETTE.map((item) => (
|
||||||
|
<DraggableComponentV4
|
||||||
|
key={item.type}
|
||||||
|
type={item.type}
|
||||||
|
label={item.label}
|
||||||
|
icon={item.icon}
|
||||||
|
description={item.description}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-xs text-muted-foreground">
|
||||||
|
캔버스로 드래그하여 배치
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
// ========================================
|
||||||
|
interface DraggableComponentV4Props {
|
||||||
|
type: PopComponentType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableComponentV4({ type, label, icon: Icon, description }: DraggableComponentV4Props) {
|
||||||
|
const [{ isDragging }, drag] = useDrag(
|
||||||
|
() => ({
|
||||||
|
type: DND_ITEM_TYPES.COMPONENT,
|
||||||
|
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent,
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isDragging: monitor.isDragging(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[type]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={drag}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg border p-2 cursor-grab transition-all",
|
||||||
|
"hover:bg-accent hover:border-primary/30",
|
||||||
|
isDragging && "opacity-50 cursor-grabbing"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ComponentPaletteV4;
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
// POP 디자이너 패널 export
|
// POP 디자이너 패널 export
|
||||||
export { PopPanel } from "./PopPanel";
|
export { PopPanel } from "./PopPanel";
|
||||||
export { ComponentEditorPanel } from "./ComponentEditorPanel";
|
export { ComponentEditorPanel } from "./ComponentEditorPanel";
|
||||||
|
export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,796 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useCallback, useRef } from "react";
|
||||||
|
import { useDrag, useDrop } from "react-dnd";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopLayoutDataV4,
|
||||||
|
PopContainerV4,
|
||||||
|
PopComponentDefinitionV4,
|
||||||
|
PopResponsiveRuleV4,
|
||||||
|
PopSizeConstraintV4,
|
||||||
|
PopComponentType,
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
|
||||||
|
// 드래그 아이템 타입
|
||||||
|
const DND_COMPONENT_REORDER = "POP_COMPONENT_REORDER";
|
||||||
|
|
||||||
|
interface DragItem {
|
||||||
|
type: string;
|
||||||
|
componentId: string;
|
||||||
|
containerId: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface PopFlexRendererProps {
|
||||||
|
/** v4 레이아웃 데이터 */
|
||||||
|
layout: PopLayoutDataV4;
|
||||||
|
/** 현재 뷰포트 너비 (반응형 규칙 적용용) */
|
||||||
|
viewportWidth: number;
|
||||||
|
/** 디자인 모드 여부 */
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
/** 선택된 컴포넌트 ID */
|
||||||
|
selectedComponentId?: string | null;
|
||||||
|
/** 컴포넌트 클릭 */
|
||||||
|
onComponentClick?: (componentId: string) => void;
|
||||||
|
/** 컨테이너 클릭 */
|
||||||
|
onContainerClick?: (containerId: string) => void;
|
||||||
|
/** 배경 클릭 */
|
||||||
|
onBackgroundClick?: () => void;
|
||||||
|
/** 컴포넌트 크기 변경 */
|
||||||
|
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
||||||
|
/** 컴포넌트 순서 변경 */
|
||||||
|
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
||||||
|
/** 추가 className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 타입별 라벨
|
||||||
|
// ========================================
|
||||||
|
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
|
"pop-field": "필드",
|
||||||
|
"pop-button": "버튼",
|
||||||
|
"pop-list": "리스트",
|
||||||
|
"pop-indicator": "인디케이터",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
|
"pop-numpad": "숫자패드",
|
||||||
|
"pop-spacer": "스페이서",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v4 Flexbox 렌더러
|
||||||
|
//
|
||||||
|
// 핵심 역할:
|
||||||
|
// - v4 레이아웃을 Flexbox CSS로 렌더링
|
||||||
|
// - 제약조건(fill/fixed/hug) 기반 크기 계산
|
||||||
|
// - 반응형 규칙(breakpoint) 자동 적용
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export function PopFlexRenderer({
|
||||||
|
layout,
|
||||||
|
viewportWidth,
|
||||||
|
isDesignMode = false,
|
||||||
|
selectedComponentId,
|
||||||
|
onComponentClick,
|
||||||
|
onContainerClick,
|
||||||
|
onBackgroundClick,
|
||||||
|
onComponentResize,
|
||||||
|
onReorderComponent,
|
||||||
|
className,
|
||||||
|
}: PopFlexRendererProps) {
|
||||||
|
const { root, components, settings } = layout;
|
||||||
|
|
||||||
|
// 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링
|
||||||
|
if (root.children.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("h-full w-full", className)}
|
||||||
|
onClick={onBackgroundClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative min-h-full w-full bg-white", className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onBackgroundClick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 루트 컨테이너 렌더링 */}
|
||||||
|
<ContainerRenderer
|
||||||
|
container={root}
|
||||||
|
components={components}
|
||||||
|
viewportWidth={viewportWidth}
|
||||||
|
settings={settings}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
onComponentClick={onComponentClick}
|
||||||
|
onContainerClick={onContainerClick}
|
||||||
|
onComponentResize={onComponentResize}
|
||||||
|
onReorderComponent={onReorderComponent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컨테이너 렌더러 (재귀)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ContainerRendererProps {
|
||||||
|
container: PopContainerV4;
|
||||||
|
components: Record<string, PopComponentDefinitionV4>;
|
||||||
|
viewportWidth: number;
|
||||||
|
settings: PopLayoutDataV4["settings"];
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
selectedComponentId?: string | null;
|
||||||
|
onComponentClick?: (componentId: string) => void;
|
||||||
|
onContainerClick?: (containerId: string) => void;
|
||||||
|
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
||||||
|
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerRenderer({
|
||||||
|
container,
|
||||||
|
components,
|
||||||
|
viewportWidth,
|
||||||
|
settings,
|
||||||
|
isDesignMode = false,
|
||||||
|
selectedComponentId,
|
||||||
|
onComponentClick,
|
||||||
|
onContainerClick,
|
||||||
|
onComponentResize,
|
||||||
|
onReorderComponent,
|
||||||
|
depth = 0,
|
||||||
|
}: ContainerRendererProps) {
|
||||||
|
// 반응형 규칙 적용
|
||||||
|
const effectiveContainer = useMemo(() => {
|
||||||
|
return applyResponsiveRules(container, viewportWidth);
|
||||||
|
}, [container, viewportWidth]);
|
||||||
|
|
||||||
|
// 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용)
|
||||||
|
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
|
||||||
|
|
||||||
|
// Flexbox 스타일 계산 (useMemo는 조건문 전에 호출해야 함)
|
||||||
|
const containerStyle = useMemo((): React.CSSProperties => {
|
||||||
|
const { direction, wrap, gap, alignItems, justifyContent, padding } = effectiveContainer;
|
||||||
|
|
||||||
|
// gap과 padding도 스케일 적용
|
||||||
|
const scaledGap = gap * scale;
|
||||||
|
const scaledPadding = padding ? padding * scale : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: direction === "horizontal" ? "row" : "column",
|
||||||
|
flexWrap: wrap ? "wrap" : "nowrap",
|
||||||
|
gap: `${scaledGap}px`,
|
||||||
|
alignItems: mapAlignment(alignItems),
|
||||||
|
justifyContent: mapJustify(justifyContent),
|
||||||
|
padding: scaledPadding ? `${scaledPadding}px` : undefined,
|
||||||
|
width: "100%",
|
||||||
|
minHeight: depth === 0 ? "100%" : undefined,
|
||||||
|
};
|
||||||
|
}, [effectiveContainer, depth, scale]);
|
||||||
|
|
||||||
|
// 숨김 처리
|
||||||
|
if (effectiveContainer.hidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative",
|
||||||
|
isDesignMode && depth > 0 && "border border-dashed border-gray-300 rounded"
|
||||||
|
)}
|
||||||
|
style={containerStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onContainerClick?.(container.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{effectiveContainer.children.map((child, index) => {
|
||||||
|
// 중첩 컨테이너인 경우
|
||||||
|
if (typeof child === "object") {
|
||||||
|
return (
|
||||||
|
<ContainerRenderer
|
||||||
|
key={child.id}
|
||||||
|
container={child}
|
||||||
|
components={components}
|
||||||
|
viewportWidth={viewportWidth}
|
||||||
|
settings={settings}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
onComponentClick={onComponentClick}
|
||||||
|
onContainerClick={onContainerClick}
|
||||||
|
onComponentResize={onComponentResize}
|
||||||
|
onReorderComponent={onReorderComponent}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 ID인 경우
|
||||||
|
const componentId = child;
|
||||||
|
const compDef = components[componentId];
|
||||||
|
if (!compDef) return null;
|
||||||
|
|
||||||
|
// 반응형 숨김 처리
|
||||||
|
if (compDef.hideBelow && viewportWidth < compDef.hideBelow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggableComponentWrapper
|
||||||
|
key={componentId}
|
||||||
|
componentId={componentId}
|
||||||
|
containerId={container.id}
|
||||||
|
index={index}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
onReorder={onReorderComponent}
|
||||||
|
>
|
||||||
|
<ComponentRendererV4
|
||||||
|
componentId={componentId}
|
||||||
|
component={compDef}
|
||||||
|
settings={settings}
|
||||||
|
viewportWidth={viewportWidth}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
isSelected={selectedComponentId === componentId}
|
||||||
|
onClick={() => onComponentClick?.(componentId)}
|
||||||
|
onResize={onComponentResize}
|
||||||
|
/>
|
||||||
|
</DraggableComponentWrapper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 드래그 가능한 컴포넌트 래퍼
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface DraggableComponentWrapperProps {
|
||||||
|
componentId: string;
|
||||||
|
containerId: string;
|
||||||
|
index: number;
|
||||||
|
isDesignMode: boolean;
|
||||||
|
onReorder?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableComponentWrapper({
|
||||||
|
componentId,
|
||||||
|
containerId,
|
||||||
|
index,
|
||||||
|
isDesignMode,
|
||||||
|
onReorder,
|
||||||
|
children,
|
||||||
|
}: DraggableComponentWrapperProps) {
|
||||||
|
// 디자인 모드가 아니면 그냥 children 반환 (훅 호출 전에 체크)
|
||||||
|
// DndProvider가 없는 환경에서 useDrag/useDrop 훅 호출 방지
|
||||||
|
if (!isDesignMode) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디자인 모드일 때만 드래그 기능 활성화
|
||||||
|
return (
|
||||||
|
<DraggableComponentWrapperInner
|
||||||
|
componentId={componentId}
|
||||||
|
containerId={containerId}
|
||||||
|
index={index}
|
||||||
|
onReorder={onReorder}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DraggableComponentWrapperInner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디자인 모드 전용 내부 컴포넌트 (DndProvider 필요)
|
||||||
|
function DraggableComponentWrapperInner({
|
||||||
|
componentId,
|
||||||
|
containerId,
|
||||||
|
index,
|
||||||
|
onReorder,
|
||||||
|
children,
|
||||||
|
}: Omit<DraggableComponentWrapperProps, "isDesignMode">) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [{ isDragging }, drag] = useDrag({
|
||||||
|
type: DND_COMPONENT_REORDER,
|
||||||
|
item: (): DragItem => ({
|
||||||
|
type: DND_COMPONENT_REORDER,
|
||||||
|
componentId,
|
||||||
|
containerId,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isDragging: monitor.isDragging(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ isOver, canDrop }, drop] = useDrop({
|
||||||
|
accept: DND_COMPONENT_REORDER,
|
||||||
|
canDrop: (item: DragItem) => {
|
||||||
|
// 같은 컨테이너 내에서만 이동 가능 (일단은)
|
||||||
|
return item.containerId === containerId && item.index !== index;
|
||||||
|
},
|
||||||
|
drop: (item: DragItem) => {
|
||||||
|
if (item.index !== index && onReorder) {
|
||||||
|
onReorder(containerId, item.index, index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOver: monitor.isOver(),
|
||||||
|
canDrop: monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// drag와 drop 합치기
|
||||||
|
drag(drop(ref));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative",
|
||||||
|
isDragging && "opacity-50",
|
||||||
|
isOver && canDrop && "ring-2 ring-blue-500 ring-offset-2"
|
||||||
|
)}
|
||||||
|
style={{ cursor: isDragging ? "grabbing" : "grab" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{/* 드롭 인디케이터 */}
|
||||||
|
{isOver && canDrop && (
|
||||||
|
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none rounded" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v4 컴포넌트 렌더러 (리사이즈 핸들 포함)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ComponentRendererV4Props {
|
||||||
|
componentId: string;
|
||||||
|
component: PopComponentDefinitionV4;
|
||||||
|
settings: PopLayoutDataV4["settings"];
|
||||||
|
viewportWidth: number;
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentRendererV4({
|
||||||
|
componentId,
|
||||||
|
component,
|
||||||
|
settings,
|
||||||
|
viewportWidth,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
onResize,
|
||||||
|
}: ComponentRendererV4Props) {
|
||||||
|
const { size, alignSelf, type, label } = component;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용)
|
||||||
|
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
|
||||||
|
|
||||||
|
// 리사이즈 상태
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [resizeDirection, setResizeDirection] = useState<"width" | "height" | "both" | null>(null);
|
||||||
|
const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||||
|
|
||||||
|
// 크기 스타일 계산 (스케일 적용)
|
||||||
|
const sizeStyle = useMemo((): React.CSSProperties => {
|
||||||
|
return calculateSizeStyle(size, settings, scale);
|
||||||
|
}, [size, settings, scale]);
|
||||||
|
|
||||||
|
// alignSelf 스타일
|
||||||
|
const alignStyle: React.CSSProperties = alignSelf
|
||||||
|
? { alignSelf: mapAlignment(alignSelf) }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const typeLabel = COMPONENT_TYPE_LABELS[type] || type;
|
||||||
|
|
||||||
|
// 리사이즈 시작
|
||||||
|
const handleResizeStart = useCallback((
|
||||||
|
e: React.MouseEvent,
|
||||||
|
direction: "width" | "height" | "both"
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
resizeStartRef.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
setIsResizing(true);
|
||||||
|
setResizeDirection(direction);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 리사이즈 중
|
||||||
|
useCallback((e: MouseEvent) => {
|
||||||
|
if (!isResizing || !resizeStartRef.current || !onResize) return;
|
||||||
|
|
||||||
|
const deltaX = e.clientX - resizeStartRef.current.x;
|
||||||
|
const deltaY = e.clientY - resizeStartRef.current.y;
|
||||||
|
|
||||||
|
const updates: Partial<PopSizeConstraintV4> = {};
|
||||||
|
|
||||||
|
if (resizeDirection === "width" || resizeDirection === "both") {
|
||||||
|
const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX));
|
||||||
|
updates.width = "fixed";
|
||||||
|
updates.fixedWidth = newWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resizeDirection === "height" || resizeDirection === "both") {
|
||||||
|
const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY));
|
||||||
|
updates.height = "fixed";
|
||||||
|
updates.fixedHeight = newHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
onResize(componentId, updates);
|
||||||
|
}, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]);
|
||||||
|
|
||||||
|
// 리사이즈 종료 및 이벤트 등록
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!resizeStartRef.current || !onResize) return;
|
||||||
|
|
||||||
|
const deltaX = e.clientX - resizeStartRef.current.x;
|
||||||
|
const deltaY = e.clientY - resizeStartRef.current.y;
|
||||||
|
|
||||||
|
const updates: Partial<PopSizeConstraintV4> = {};
|
||||||
|
|
||||||
|
if (resizeDirection === "width" || resizeDirection === "both") {
|
||||||
|
const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX));
|
||||||
|
updates.width = "fixed";
|
||||||
|
updates.fixedWidth = newWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resizeDirection === "height" || resizeDirection === "both") {
|
||||||
|
const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY));
|
||||||
|
updates.height = "fixed";
|
||||||
|
updates.fixedHeight = newHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
onResize(componentId, updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
setResizeDirection(null);
|
||||||
|
resizeStartRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col overflow-visible rounded-lg border-2 bg-white transition-all",
|
||||||
|
isSelected
|
||||||
|
? "border-primary ring-2 ring-primary/30 z-10"
|
||||||
|
: "border-gray-200",
|
||||||
|
isDesignMode && !isResizing && "cursor-pointer hover:border-gray-300",
|
||||||
|
isResizing && "select-none"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
...sizeStyle,
|
||||||
|
...alignStyle,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isResizing) {
|
||||||
|
onClick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
|
||||||
|
{isDesignMode && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-5 shrink-0 items-center border-b px-2",
|
||||||
|
isSelected ? "bg-primary/10" : "bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-medium text-gray-600">
|
||||||
|
{label || typeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컴포넌트 내용 */}
|
||||||
|
<div className="flex flex-1 items-center justify-center p-2 overflow-hidden">
|
||||||
|
{renderComponentContent(component, isDesignMode, settings)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리사이즈 핸들 (디자인 모드 + 선택 시에만) */}
|
||||||
|
{isDesignMode && isSelected && onResize && (
|
||||||
|
<>
|
||||||
|
{/* 오른쪽 핸들 (너비 조정) */}
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize hover:bg-primary/80 flex items-center justify-center z-20"
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, "width")}
|
||||||
|
title="너비 조정"
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-4 bg-white rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 아래쪽 핸들 (높이 조정) */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-3 w-8 bg-primary rounded cursor-ns-resize hover:bg-primary/80 flex items-center justify-center z-20"
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, "height")}
|
||||||
|
title="높이 조정"
|
||||||
|
>
|
||||||
|
<div className="h-0.5 w-4 bg-white rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 아래 핸들 (너비 + 높이 동시 조정) */}
|
||||||
|
<div
|
||||||
|
className="absolute right-0 bottom-0 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-primary rounded cursor-nwse-resize hover:bg-primary/80 flex items-center justify-center z-20"
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, "both")}
|
||||||
|
title="크기 조정"
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 border-r-2 border-b-2 border-white" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 헬퍼 함수들
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반응형 규칙 적용
|
||||||
|
*/
|
||||||
|
function applyResponsiveRules(
|
||||||
|
container: PopContainerV4,
|
||||||
|
viewportWidth: number
|
||||||
|
): PopContainerV4 & { hidden?: boolean } {
|
||||||
|
if (!container.responsive || container.responsive.length === 0) {
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 뷰포트에 적용되는 규칙 찾기 (가장 큰 breakpoint부터)
|
||||||
|
const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint);
|
||||||
|
const applicableRule = sortedRules.find((rule) => viewportWidth <= rule.breakpoint);
|
||||||
|
|
||||||
|
if (!applicableRule) {
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...container,
|
||||||
|
direction: applicableRule.direction ?? container.direction,
|
||||||
|
gap: applicableRule.gap ?? container.gap,
|
||||||
|
hidden: applicableRule.hidden ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기준 뷰포트 너비 (10인치 태블릿 가로)
|
||||||
|
* 이 크기를 기준으로 컴포넌트 크기가 비율 조정됨
|
||||||
|
*/
|
||||||
|
const BASE_VIEWPORT_WIDTH = 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 크기 제약 → CSS 스타일 변환
|
||||||
|
* @param scale - 뷰포트 비율 (viewportWidth / BASE_VIEWPORT_WIDTH)
|
||||||
|
*/
|
||||||
|
function calculateSizeStyle(
|
||||||
|
size: PopSizeConstraintV4,
|
||||||
|
settings: PopLayoutDataV4["settings"],
|
||||||
|
scale: number = 1
|
||||||
|
): React.CSSProperties {
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
|
||||||
|
// 스케일된 터치 최소 크기
|
||||||
|
const scaledTouchMin = settings.touchTargetMin * scale;
|
||||||
|
|
||||||
|
// 너비
|
||||||
|
switch (size.width) {
|
||||||
|
case "fixed":
|
||||||
|
// fixed 크기도 비율에 맞게 스케일
|
||||||
|
style.width = size.fixedWidth ? `${size.fixedWidth * scale}px` : "auto";
|
||||||
|
style.flexShrink = 0;
|
||||||
|
break;
|
||||||
|
case "fill":
|
||||||
|
style.flex = 1;
|
||||||
|
style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0;
|
||||||
|
style.maxWidth = size.maxWidth ? `${size.maxWidth * scale}px` : undefined;
|
||||||
|
break;
|
||||||
|
case "hug":
|
||||||
|
style.width = "auto";
|
||||||
|
style.flexShrink = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 높이
|
||||||
|
switch (size.height) {
|
||||||
|
case "fixed":
|
||||||
|
const scaledFixedHeight = (size.fixedHeight || settings.touchTargetMin) * scale;
|
||||||
|
const minHeight = Math.max(scaledFixedHeight, scaledTouchMin);
|
||||||
|
style.height = `${minHeight}px`;
|
||||||
|
break;
|
||||||
|
case "fill":
|
||||||
|
style.flexGrow = 1;
|
||||||
|
style.minHeight = size.minHeight
|
||||||
|
? `${Math.max(size.minHeight * scale, scaledTouchMin)}px`
|
||||||
|
: `${scaledTouchMin}px`;
|
||||||
|
break;
|
||||||
|
case "hug":
|
||||||
|
style.height = "auto";
|
||||||
|
style.minHeight = `${scaledTouchMin}px`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* alignItems 값 변환
|
||||||
|
*/
|
||||||
|
function mapAlignment(value: string): React.CSSProperties["alignItems"] {
|
||||||
|
switch (value) {
|
||||||
|
case "start":
|
||||||
|
return "flex-start";
|
||||||
|
case "end":
|
||||||
|
return "flex-end";
|
||||||
|
case "center":
|
||||||
|
return "center";
|
||||||
|
case "stretch":
|
||||||
|
return "stretch";
|
||||||
|
default:
|
||||||
|
return "stretch";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* justifyContent 값 변환
|
||||||
|
*/
|
||||||
|
function mapJustify(value: string): React.CSSProperties["justifyContent"] {
|
||||||
|
switch (value) {
|
||||||
|
case "start":
|
||||||
|
return "flex-start";
|
||||||
|
case "end":
|
||||||
|
return "flex-end";
|
||||||
|
case "center":
|
||||||
|
return "center";
|
||||||
|
case "space-between":
|
||||||
|
return "space-between";
|
||||||
|
default:
|
||||||
|
return "flex-start";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트별 내용 렌더링
|
||||||
|
*/
|
||||||
|
function renderComponentContent(
|
||||||
|
component: PopComponentDefinitionV4,
|
||||||
|
isDesignMode: boolean,
|
||||||
|
settings: PopLayoutDataV4["settings"]
|
||||||
|
): React.ReactNode {
|
||||||
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||||
|
|
||||||
|
// 디자인 모드에서는 플레이스홀더 표시
|
||||||
|
if (isDesignMode) {
|
||||||
|
// Spacer는 디자인 모드에서 점선 배경으로 표시
|
||||||
|
if (component.type === "pop-spacer") {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50/50 rounded">
|
||||||
|
<span className="text-xs text-gray-400">빈 공간</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-400 text-center">
|
||||||
|
{typeLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 뷰어 모드: 실제 컴포넌트 렌더링
|
||||||
|
switch (component.type) {
|
||||||
|
case "pop-field":
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={component.label || "입력하세요"}
|
||||||
|
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-button":
|
||||||
|
return (
|
||||||
|
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
|
||||||
|
{component.label || "버튼"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-list":
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full overflow-auto p-2">
|
||||||
|
<div className="text-xs text-gray-500 text-center">
|
||||||
|
리스트 (데이터 연결 필요)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-indicator":
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-primary">0</div>
|
||||||
|
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-scanner":
|
||||||
|
return (
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<div className="text-xs">스캐너</div>
|
||||||
|
<div className="text-[10px]">탭하여 스캔</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-numpad":
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-1 p-1 w-full">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pop-spacer":
|
||||||
|
// 실제 모드에서 Spacer는 완전히 투명 (공간만 차지)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{typeLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PopFlexRenderer;
|
||||||
|
|
@ -148,11 +148,11 @@ export const createEmptyPopLayoutV4 = (): PopLayoutDataV4 => ({
|
||||||
root: {
|
root: {
|
||||||
id: "root",
|
id: "root",
|
||||||
type: "stack",
|
type: "stack",
|
||||||
direction: "vertical",
|
direction: "horizontal", // 가로 방향 (왼쪽→오른쪽)
|
||||||
wrap: false,
|
wrap: true, // 자동 줄바꿈 활성화
|
||||||
gap: 8,
|
gap: 8,
|
||||||
alignItems: "stretch",
|
alignItems: "start", // 위쪽 정렬
|
||||||
justifyContent: "start",
|
justifyContent: "start", // 왼쪽 정렬
|
||||||
padding: 16,
|
padding: 16,
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
|
|
@ -166,6 +166,238 @@ export const createEmptyPopLayoutV4 = (): PopLayoutDataV4 => ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4 컴포넌트 정의 생성
|
||||||
|
* - 컴포넌트 타입별 기본 크기 설정
|
||||||
|
*/
|
||||||
|
export const createComponentDefinitionV4 = (
|
||||||
|
id: string,
|
||||||
|
type: PopComponentType,
|
||||||
|
label?: string
|
||||||
|
): PopComponentDefinitionV4 => {
|
||||||
|
// 타입별 기본 크기 설정
|
||||||
|
const defaultSizes: Record<PopComponentType, PopSizeConstraintV4> = {
|
||||||
|
"pop-field": {
|
||||||
|
width: "fixed",
|
||||||
|
height: "fixed",
|
||||||
|
fixedWidth: 200,
|
||||||
|
fixedHeight: 48,
|
||||||
|
},
|
||||||
|
"pop-button": {
|
||||||
|
width: "fixed",
|
||||||
|
height: "fixed",
|
||||||
|
fixedWidth: 120,
|
||||||
|
fixedHeight: 48,
|
||||||
|
},
|
||||||
|
"pop-list": {
|
||||||
|
width: "fill",
|
||||||
|
height: "fixed",
|
||||||
|
fixedHeight: 200,
|
||||||
|
},
|
||||||
|
"pop-indicator": {
|
||||||
|
width: "fixed",
|
||||||
|
height: "fixed",
|
||||||
|
fixedWidth: 120,
|
||||||
|
fixedHeight: 80,
|
||||||
|
},
|
||||||
|
"pop-scanner": {
|
||||||
|
width: "fixed",
|
||||||
|
height: "fixed",
|
||||||
|
fixedWidth: 200,
|
||||||
|
fixedHeight: 48,
|
||||||
|
},
|
||||||
|
"pop-numpad": {
|
||||||
|
width: "fixed",
|
||||||
|
height: "fixed",
|
||||||
|
fixedWidth: 200,
|
||||||
|
fixedHeight: 280,
|
||||||
|
},
|
||||||
|
"pop-spacer": {
|
||||||
|
width: "fill", // Spacer는 기본적으로 남은 공간 채움
|
||||||
|
height: "fixed",
|
||||||
|
fixedHeight: 48,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
size: defaultSizes[type] || {
|
||||||
|
width: "fixed",
|
||||||
|
height: "fixed",
|
||||||
|
fixedWidth: 100,
|
||||||
|
fixedHeight: 48,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4 레이아웃에 컴포넌트 추가
|
||||||
|
*/
|
||||||
|
export const addComponentToV4Layout = (
|
||||||
|
layout: PopLayoutDataV4,
|
||||||
|
componentId: string,
|
||||||
|
type: PopComponentType,
|
||||||
|
containerId: string = "root",
|
||||||
|
label?: string
|
||||||
|
): PopLayoutDataV4 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 컴포넌트 정의 추가
|
||||||
|
newLayout.components = {
|
||||||
|
...newLayout.components,
|
||||||
|
[componentId]: createComponentDefinitionV4(componentId, type, label),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컨테이너에 컴포넌트 ID 추가
|
||||||
|
if (containerId === "root") {
|
||||||
|
newLayout.root = {
|
||||||
|
...newLayout.root,
|
||||||
|
children: [...newLayout.root.children, componentId],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 중첩 컨테이너의 경우 재귀적으로 찾아서 추가
|
||||||
|
newLayout.root = addChildToContainer(newLayout.root, containerId, componentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컨테이너에 자식 추가 (재귀)
|
||||||
|
*/
|
||||||
|
function addChildToContainer(
|
||||||
|
container: PopContainerV4,
|
||||||
|
targetId: string,
|
||||||
|
childId: string
|
||||||
|
): PopContainerV4 {
|
||||||
|
if (container.id === targetId) {
|
||||||
|
return {
|
||||||
|
...container,
|
||||||
|
children: [...container.children, childId],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...container,
|
||||||
|
children: container.children.map((child) => {
|
||||||
|
if (typeof child === "object") {
|
||||||
|
return addChildToContainer(child, targetId, childId);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4 레이아웃에서 컴포넌트 삭제
|
||||||
|
*/
|
||||||
|
export const removeComponentFromV4Layout = (
|
||||||
|
layout: PopLayoutDataV4,
|
||||||
|
componentId: string
|
||||||
|
): PopLayoutDataV4 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 컴포넌트 정의 삭제
|
||||||
|
const { [componentId]: _, ...remainingComponents } = newLayout.components;
|
||||||
|
newLayout.components = remainingComponents;
|
||||||
|
|
||||||
|
// 컨테이너에서 컴포넌트 ID 제거
|
||||||
|
newLayout.root = removeChildFromContainer(newLayout.root, componentId);
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컨테이너에서 자식 제거 (재귀)
|
||||||
|
*/
|
||||||
|
function removeChildFromContainer(
|
||||||
|
container: PopContainerV4,
|
||||||
|
childId: string
|
||||||
|
): PopContainerV4 {
|
||||||
|
return {
|
||||||
|
...container,
|
||||||
|
children: container.children
|
||||||
|
.filter((child) => child !== childId)
|
||||||
|
.map((child) => {
|
||||||
|
if (typeof child === "object") {
|
||||||
|
return removeChildFromContainer(child, childId);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4 컴포넌트 업데이트
|
||||||
|
*/
|
||||||
|
export const updateComponentInV4Layout = (
|
||||||
|
layout: PopLayoutDataV4,
|
||||||
|
componentId: string,
|
||||||
|
updates: Partial<PopComponentDefinitionV4>
|
||||||
|
): PopLayoutDataV4 => {
|
||||||
|
const existingComponent = layout.components[componentId];
|
||||||
|
if (!existingComponent) return layout;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
components: {
|
||||||
|
...layout.components,
|
||||||
|
[componentId]: {
|
||||||
|
...existingComponent,
|
||||||
|
...updates,
|
||||||
|
// size는 깊은 병합
|
||||||
|
size: updates.size
|
||||||
|
? { ...existingComponent.size, ...updates.size }
|
||||||
|
: existingComponent.size,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4 컨테이너 찾기 (재귀)
|
||||||
|
*/
|
||||||
|
export const findContainerV4 = (
|
||||||
|
root: PopContainerV4,
|
||||||
|
containerId: string
|
||||||
|
): PopContainerV4 | null => {
|
||||||
|
if (root.id === containerId) return root;
|
||||||
|
|
||||||
|
for (const child of root.children) {
|
||||||
|
if (typeof child === "object") {
|
||||||
|
const found = findContainerV4(child, containerId);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4 컨테이너 업데이트 (재귀)
|
||||||
|
*/
|
||||||
|
export const updateContainerV4 = (
|
||||||
|
container: PopContainerV4,
|
||||||
|
containerId: string,
|
||||||
|
updates: Partial<PopContainerV4>
|
||||||
|
): PopContainerV4 => {
|
||||||
|
if (container.id === containerId) {
|
||||||
|
return { ...container, ...updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...container,
|
||||||
|
children: container.children.map((child) => {
|
||||||
|
if (typeof child === "object") {
|
||||||
|
return updateContainerV4(child, containerId, updates);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레이아웃 모드 키 (v3용)
|
// 레이아웃 모드 키 (v3용)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -369,7 +601,7 @@ export interface PopComponentDefinition {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* POP 컴포넌트 타입
|
||||||
* - 6개 핵심 컴포넌트
|
* - 7개 핵심 컴포넌트 (Spacer 포함)
|
||||||
*/
|
*/
|
||||||
export type PopComponentType =
|
export type PopComponentType =
|
||||||
| "pop-field" // 데이터 입력/표시
|
| "pop-field" // 데이터 입력/표시
|
||||||
|
|
@ -377,7 +609,8 @@ export type PopComponentType =
|
||||||
| "pop-list" // 데이터 목록 (카드 템플릿 포함)
|
| "pop-list" // 데이터 목록 (카드 템플릿 포함)
|
||||||
| "pop-indicator" // 상태/수치 표시
|
| "pop-indicator" // 상태/수치 표시
|
||||||
| "pop-scanner" // 바코드/QR 입력
|
| "pop-scanner" // 바코드/QR 입력
|
||||||
| "pop-numpad"; // 숫자 입력 특화
|
| "pop-numpad" // 숫자 입력 특화
|
||||||
|
| "pop-spacer"; // 빈 공간 (레이아웃 정렬용)
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 데이터 흐름
|
// 데이터 흐름
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 레이아웃 히스토리 훅
|
||||||
|
// Undo/Redo 기능 제공
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface HistoryState<T> {
|
||||||
|
past: T[];
|
||||||
|
present: T;
|
||||||
|
future: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseLayoutHistoryReturn<T> {
|
||||||
|
// 현재 상태
|
||||||
|
state: T;
|
||||||
|
// 상태 설정 (히스토리에 기록)
|
||||||
|
setState: (newState: T | ((prev: T) => T)) => void;
|
||||||
|
// Undo
|
||||||
|
undo: () => void;
|
||||||
|
// Redo
|
||||||
|
redo: () => void;
|
||||||
|
// Undo 가능 여부
|
||||||
|
canUndo: boolean;
|
||||||
|
// Redo 가능 여부
|
||||||
|
canRedo: boolean;
|
||||||
|
// 히스토리 초기화 (새 레이아웃 로드 시)
|
||||||
|
reset: (initialState: T) => void;
|
||||||
|
// 히스토리 크기
|
||||||
|
historySize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 히스토리 관리 훅
|
||||||
|
* @param initialState 초기 상태
|
||||||
|
* @param maxHistory 최대 히스토리 개수 (기본 50)
|
||||||
|
*/
|
||||||
|
export function useLayoutHistory<T>(
|
||||||
|
initialState: T,
|
||||||
|
maxHistory: number = 50
|
||||||
|
): UseLayoutHistoryReturn<T> {
|
||||||
|
const [history, setHistory] = useState<HistoryState<T>>({
|
||||||
|
past: [],
|
||||||
|
present: initialState,
|
||||||
|
future: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 배치 업데이트를 위한 타이머
|
||||||
|
const batchTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const pendingStateRef = useRef<T | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 설정 (히스토리에 기록)
|
||||||
|
* 연속적인 드래그 등의 작업 시 배치 처리
|
||||||
|
*/
|
||||||
|
const setState = useCallback(
|
||||||
|
(newState: T | ((prev: T) => T)) => {
|
||||||
|
setHistory((prev) => {
|
||||||
|
const resolvedState =
|
||||||
|
typeof newState === "function"
|
||||||
|
? (newState as (prev: T) => T)(prev.present)
|
||||||
|
: newState;
|
||||||
|
|
||||||
|
// 같은 상태면 무시
|
||||||
|
if (JSON.stringify(resolvedState) === JSON.stringify(prev.present)) {
|
||||||
|
console.log("[History] 상태 동일, 무시");
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 히스토리에 현재 상태 추가
|
||||||
|
const newPast = [...prev.past, prev.present];
|
||||||
|
|
||||||
|
// 최대 히스토리 개수 제한
|
||||||
|
if (newPast.length > maxHistory) {
|
||||||
|
newPast.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[History] 상태 저장, past 크기:", newPast.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
past: newPast,
|
||||||
|
present: resolvedState,
|
||||||
|
future: [], // Redo 히스토리 초기화
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[maxHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 상태 설정 (드래그 중 연속 업데이트용)
|
||||||
|
* 마지막 상태만 히스토리에 기록
|
||||||
|
*/
|
||||||
|
const setStateBatched = useCallback(
|
||||||
|
(newState: T | ((prev: T) => T), batchDelay: number = 300) => {
|
||||||
|
// 현재 상태 업데이트 (히스토리에는 바로 기록하지 않음)
|
||||||
|
setHistory((prev) => {
|
||||||
|
const resolvedState =
|
||||||
|
typeof newState === "function"
|
||||||
|
? (newState as (prev: T) => T)(prev.present)
|
||||||
|
: newState;
|
||||||
|
|
||||||
|
pendingStateRef.current = prev.present;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
present: resolvedState,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 배치 타이머 리셋
|
||||||
|
if (batchTimerRef.current) {
|
||||||
|
clearTimeout(batchTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일정 시간 후 히스토리에 기록
|
||||||
|
batchTimerRef.current = setTimeout(() => {
|
||||||
|
if (pendingStateRef.current !== null) {
|
||||||
|
setHistory((prev) => {
|
||||||
|
const newPast = [...prev.past, pendingStateRef.current as T];
|
||||||
|
if (newPast.length > maxHistory) {
|
||||||
|
newPast.shift();
|
||||||
|
}
|
||||||
|
pendingStateRef.current = null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
past: newPast,
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, batchDelay);
|
||||||
|
},
|
||||||
|
[maxHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo - 이전 상태로 복원
|
||||||
|
*/
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
console.log("[History] Undo 호출");
|
||||||
|
setHistory((prev) => {
|
||||||
|
console.log("[History] Undo 실행, past 크기:", prev.past.length);
|
||||||
|
if (prev.past.length === 0) {
|
||||||
|
console.log("[History] Undo 불가 - past 비어있음");
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPast = [...prev.past];
|
||||||
|
const previousState = newPast.pop()!;
|
||||||
|
|
||||||
|
console.log("[History] Undo 성공, 남은 past 크기:", newPast.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
past: newPast,
|
||||||
|
present: previousState,
|
||||||
|
future: [prev.present, ...prev.future],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redo - 되돌린 상태 다시 적용
|
||||||
|
*/
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
setHistory((prev) => {
|
||||||
|
if (prev.future.length === 0) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFuture = [...prev.future];
|
||||||
|
const nextState = newFuture.shift()!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
past: [...prev.past, prev.present],
|
||||||
|
present: nextState,
|
||||||
|
future: newFuture,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 히스토리 초기화 (새 레이아웃 로드 시)
|
||||||
|
*/
|
||||||
|
const reset = useCallback((initialState: T) => {
|
||||||
|
setHistory({
|
||||||
|
past: [],
|
||||||
|
present: initialState,
|
||||||
|
future: [],
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: history.present,
|
||||||
|
setState,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo: history.past.length > 0,
|
||||||
|
canRedo: history.future.length > 0,
|
||||||
|
reset,
|
||||||
|
historySize: history.past.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useLayoutHistory;
|
||||||
|
|
@ -0,0 +1,529 @@
|
||||||
|
# POP 화면 시스템 아키텍처
|
||||||
|
|
||||||
|
**최종 업데이트: 2026-02-04**
|
||||||
|
|
||||||
|
POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다.
|
||||||
|
이 문서는 POP 화면 구현에 관련된 모든 파일과 그 역할을 정리합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [폴더 구조 개요](#1-폴더-구조-개요)
|
||||||
|
2. [App 라우팅 (app/(pop))](#2-app-라우팅-apppop)
|
||||||
|
3. [컴포넌트 (components/pop)](#3-컴포넌트-componentspop)
|
||||||
|
4. [라이브러리 (lib)](#4-라이브러리-lib)
|
||||||
|
5. [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템)
|
||||||
|
6. [데이터 흐름](#6-데이터-흐름)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 폴더 구조 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── app/(pop)/ # Next.js App Router - POP 라우팅
|
||||||
|
│ ├── layout.tsx # POP 전용 레이아웃
|
||||||
|
│ └── pop/
|
||||||
|
│ ├── page.tsx # POP 대시보드 (메인)
|
||||||
|
│ ├── screens/[screenId]/ # 개별 POP 화면 뷰어
|
||||||
|
│ ├── test-v4/ # v4 렌더러 테스트 페이지
|
||||||
|
│ └── work/ # 작업 화면
|
||||||
|
│
|
||||||
|
├── components/pop/ # POP 컴포넌트 라이브러리
|
||||||
|
│ ├── designer/ # 디자이너 모듈
|
||||||
|
│ │ ├── panels/ # 편집 패널 (좌측/우측)
|
||||||
|
│ │ ├── renderers/ # 레이아웃 렌더러
|
||||||
|
│ │ └── types/ # 타입 정의
|
||||||
|
│ ├── management/ # 화면 관리 모듈
|
||||||
|
│ └── dashboard/ # 대시보드 모듈
|
||||||
|
│
|
||||||
|
└── lib/
|
||||||
|
├── api/popScreenGroup.ts # POP 화면 그룹 API
|
||||||
|
├── registry/PopComponentRegistry.ts # 컴포넌트 레지스트리
|
||||||
|
└── schemas/popComponentConfig.ts # 컴포넌트 설정 스키마
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. App 라우팅 (app/(pop))
|
||||||
|
|
||||||
|
### `app/(pop)/layout.tsx`
|
||||||
|
|
||||||
|
POP 전용 레이아웃. 데스크톱 레이아웃과 분리되어 터치 최적화 환경 제공.
|
||||||
|
|
||||||
|
### `app/(pop)/pop/page.tsx`
|
||||||
|
|
||||||
|
**경로**: `/pop`
|
||||||
|
|
||||||
|
POP 메인 대시보드. 메뉴 그리드, KPI, 공지사항 등을 표시.
|
||||||
|
|
||||||
|
### `app/(pop)/pop/screens/[screenId]/page.tsx`
|
||||||
|
|
||||||
|
**경로**: `/pop/screens/:screenId`
|
||||||
|
|
||||||
|
**역할**: 개별 POP 화면 뷰어 (디자인 모드 X, 실행 모드)
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- v3/v4 레이아웃 자동 감지 및 렌더링
|
||||||
|
- 반응형 모드 감지 (태블릿/모바일, 가로/세로)
|
||||||
|
- 프리뷰 모드 지원 (`?preview=true`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 레이아웃 버전 감지 및 렌더링
|
||||||
|
if (popLayoutV4) {
|
||||||
|
// v4: PopFlexRenderer 사용
|
||||||
|
<PopFlexRenderer layout={popLayoutV4} viewportWidth={...} />
|
||||||
|
} else if (popLayoutV3) {
|
||||||
|
// v3: PopLayoutRenderer 사용
|
||||||
|
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/(pop)/pop/test-v4/page.tsx`
|
||||||
|
|
||||||
|
**경로**: `/pop/test-v4`
|
||||||
|
|
||||||
|
**역할**: v4 레이아웃 시스템 테스트 페이지
|
||||||
|
|
||||||
|
**구성**:
|
||||||
|
- 왼쪽: 컴포넌트 팔레트 (PopPanel)
|
||||||
|
- 중앙: v4 캔버스 (PopCanvasV4)
|
||||||
|
- 오른쪽: 속성 패널 (ComponentEditorPanelV4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 컴포넌트 (components/pop)
|
||||||
|
|
||||||
|
### 3.1 디자이너 모듈 (`designer/`)
|
||||||
|
|
||||||
|
#### `PopDesigner.tsx`
|
||||||
|
|
||||||
|
**역할**: POP 화면 디자이너 메인 컴포넌트
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- v3/v4 모드 전환 (상단 탭)
|
||||||
|
- 레이아웃 로드/저장
|
||||||
|
- 컴포넌트 추가/삭제/수정
|
||||||
|
- 드래그 앤 드롭 (react-dnd)
|
||||||
|
|
||||||
|
**상태 관리**:
|
||||||
|
```typescript
|
||||||
|
const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3");
|
||||||
|
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(...);
|
||||||
|
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(...);
|
||||||
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
**레이아웃**:
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 툴바 (뒤로가기, 화면명, 모드전환, 저장) │
|
||||||
|
├──────────┬──────────────────────────┬──────────┤
|
||||||
|
│ 왼쪽 │ 중앙 캔버스 │ 오른쪽 │
|
||||||
|
│ 패널 │ │ 패널 │
|
||||||
|
│ (20%) │ (60%) │ (20%) │
|
||||||
|
│ │ │ (v4만) │
|
||||||
|
└──────────┴──────────────────────────┴──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `PopCanvas.tsx` (v3용)
|
||||||
|
|
||||||
|
**역할**: v3 레이아웃용 CSS Grid 기반 캔버스
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- 4개 모드 전환 (태블릿 가로/세로, 모바일 가로/세로)
|
||||||
|
- 그리드 기반 컴포넌트 배치
|
||||||
|
- 드래그로 위치/크기 조정
|
||||||
|
|
||||||
|
#### `PopCanvasV4.tsx` (v4용)
|
||||||
|
|
||||||
|
**역할**: v4 레이아웃용 Flexbox 기반 캔버스
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- 단일 캔버스 + 뷰포트 프리뷰
|
||||||
|
- 3가지 프리셋 (모바일 375px, 태블릿 768px, 데스크톱 1024px)
|
||||||
|
- 너비 슬라이더로 반응형 테스트
|
||||||
|
- 줌 컨트롤 (30%~150%)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const VIEWPORT_PRESETS = [
|
||||||
|
{ id: "mobile", label: "모바일", width: 375, height: 667 },
|
||||||
|
{ id: "tablet", label: "태블릿", width: 768, height: 1024 },
|
||||||
|
{ id: "desktop", label: "데스크톱", width: 1024, height: 768 },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 패널 모듈 (`designer/panels/`)
|
||||||
|
|
||||||
|
#### `PopPanel.tsx`
|
||||||
|
|
||||||
|
**역할**: 왼쪽 패널 - 컴포넌트 팔레트 & 편집 탭
|
||||||
|
|
||||||
|
**탭 구성**:
|
||||||
|
1. **컴포넌트 탭**: 드래그 가능한 6개 컴포넌트
|
||||||
|
2. **편집 탭**: 선택된 컴포넌트 설정
|
||||||
|
|
||||||
|
**컴포넌트 팔레트**:
|
||||||
|
```typescript
|
||||||
|
const COMPONENT_PALETTE = [
|
||||||
|
{ type: "pop-field", label: "필드", description: "텍스트, 숫자 등 데이터 입력" },
|
||||||
|
{ type: "pop-button", label: "버튼", description: "저장, 삭제 등 액션 실행" },
|
||||||
|
{ type: "pop-list", label: "리스트", description: "데이터 목록" },
|
||||||
|
{ type: "pop-indicator", label: "인디케이터", description: "KPI, 상태 표시" },
|
||||||
|
{ type: "pop-scanner", label: "스캐너", description: "바코드/QR 스캔" },
|
||||||
|
{ type: "pop-numpad", label: "숫자패드", description: "숫자 입력 전용" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**드래그 아이템 타입**:
|
||||||
|
```typescript
|
||||||
|
export const DND_ITEM_TYPES = { COMPONENT: "component" };
|
||||||
|
export interface DragItemComponent {
|
||||||
|
type: typeof DND_ITEM_TYPES.COMPONENT;
|
||||||
|
componentType: PopComponentType;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ComponentEditorPanelV4.tsx`
|
||||||
|
|
||||||
|
**역할**: v4 오른쪽 패널 - 컴포넌트/컨테이너 속성 편집
|
||||||
|
|
||||||
|
**3개 탭**:
|
||||||
|
1. **크기 탭**: 너비/높이 제약 (fixed/fill/hug)
|
||||||
|
2. **설정 탭**: 라벨, 타입별 설정
|
||||||
|
3. **데이터 탭**: 데이터 바인딩 (미구현)
|
||||||
|
|
||||||
|
**크기 제약 편집**:
|
||||||
|
```typescript
|
||||||
|
// 너비/높이 모드
|
||||||
|
type SizeMode = "fixed" | "fill" | "hug";
|
||||||
|
|
||||||
|
// fixed: 고정 px 값
|
||||||
|
// fill: 남은 공간 채움 (flex: 1)
|
||||||
|
// hug: 내용에 맞춤 (width: auto)
|
||||||
|
```
|
||||||
|
|
||||||
|
**컨테이너 설정**:
|
||||||
|
- 방향 (horizontal/vertical)
|
||||||
|
- 줄바꿈 (wrap)
|
||||||
|
- 간격 (gap)
|
||||||
|
- 패딩 (padding)
|
||||||
|
- 정렬 (alignItems, justifyContent)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 렌더러 모듈 (`designer/renderers/`)
|
||||||
|
|
||||||
|
#### `PopLayoutRenderer.tsx` (v3용)
|
||||||
|
|
||||||
|
**역할**: v3 레이아웃을 CSS Grid로 렌더링
|
||||||
|
|
||||||
|
**입력**:
|
||||||
|
- `layout`: PopLayoutDataV3
|
||||||
|
- `modeKey`: 현재 모드 (tablet_landscape 등)
|
||||||
|
- `isDesignMode`: 디자인 모드 여부
|
||||||
|
|
||||||
|
#### `PopFlexRenderer.tsx` (v4용)
|
||||||
|
|
||||||
|
**역할**: v4 레이아웃을 Flexbox로 렌더링
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- 컨테이너 재귀 렌더링
|
||||||
|
- 반응형 규칙 적용 (breakpoint)
|
||||||
|
- 크기 제약 → CSS 스타일 변환
|
||||||
|
- 컴포넌트 숨김 처리 (hideBelow)
|
||||||
|
|
||||||
|
**크기 제약 변환 로직**:
|
||||||
|
```typescript
|
||||||
|
function calculateSizeStyle(size: PopSizeConstraintV4): React.CSSProperties {
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
|
||||||
|
// 너비
|
||||||
|
switch (size.width) {
|
||||||
|
case "fixed": style.width = `${size.fixedWidth}px`; style.flexShrink = 0; break;
|
||||||
|
case "fill": style.flex = 1; style.minWidth = size.minWidth || 0; break;
|
||||||
|
case "hug": style.width = "auto"; style.flexShrink = 0; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 높이
|
||||||
|
switch (size.height) {
|
||||||
|
case "fixed": style.height = `${size.fixedHeight}px`; break;
|
||||||
|
case "fill": style.flexGrow = 1; break;
|
||||||
|
case "hug": style.height = "auto"; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ComponentRenderer.tsx`
|
||||||
|
|
||||||
|
**역할**: 개별 컴포넌트 렌더링 (디자인 모드용 플레이스홀더)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 타입 정의 (`designer/types/`)
|
||||||
|
|
||||||
|
#### `pop-layout.ts`
|
||||||
|
|
||||||
|
**역할**: POP 레이아웃 전체 타입 시스템 정의
|
||||||
|
|
||||||
|
**파일 크기**: 1442줄 (v1~v4 모든 버전 포함)
|
||||||
|
|
||||||
|
상세 내용은 [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템) 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 관리 모듈 (`management/`)
|
||||||
|
|
||||||
|
#### `PopCategoryTree.tsx`
|
||||||
|
|
||||||
|
POP 화면 카테고리 트리 컴포넌트
|
||||||
|
|
||||||
|
#### `PopScreenSettingModal.tsx`
|
||||||
|
|
||||||
|
POP 화면 설정 모달
|
||||||
|
|
||||||
|
#### `PopScreenPreview.tsx`
|
||||||
|
|
||||||
|
POP 화면 미리보기
|
||||||
|
|
||||||
|
#### `PopScreenFlowView.tsx`
|
||||||
|
|
||||||
|
화면 간 플로우 시각화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 대시보드 모듈 (`dashboard/`)
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `PopDashboard.tsx` | 대시보드 메인 컴포넌트 |
|
||||||
|
| `DashboardHeader.tsx` | 상단 헤더 (로고, 시간, 사용자) |
|
||||||
|
| `DashboardFooter.tsx` | 하단 푸터 |
|
||||||
|
| `MenuGrid.tsx` | 메뉴 그리드 (앱 아이콘 형태) |
|
||||||
|
| `KpiBar.tsx` | KPI 요약 바 |
|
||||||
|
| `NoticeBanner.tsx` | 공지 배너 |
|
||||||
|
| `NoticeList.tsx` | 공지 목록 |
|
||||||
|
| `ActivityList.tsx` | 최근 활동 목록 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 라이브러리 (lib)
|
||||||
|
|
||||||
|
### `lib/api/popScreenGroup.ts`
|
||||||
|
|
||||||
|
**역할**: POP 화면 그룹 API 클라이언트
|
||||||
|
|
||||||
|
**API 함수**:
|
||||||
|
```typescript
|
||||||
|
// 조회
|
||||||
|
getPopScreenGroups(searchTerm?: string): Promise<PopScreenGroup[]>
|
||||||
|
|
||||||
|
// 생성
|
||||||
|
createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...>
|
||||||
|
|
||||||
|
// 수정
|
||||||
|
updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...>
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
deletePopScreenGroup(id: number): Promise<...>
|
||||||
|
|
||||||
|
// 루트 그룹 확보
|
||||||
|
ensurePopRootGroup(): Promise<...>
|
||||||
|
```
|
||||||
|
|
||||||
|
**트리 변환 유틸리티**:
|
||||||
|
```typescript
|
||||||
|
// 플랫 리스트 → 트리 구조
|
||||||
|
buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `lib/registry/PopComponentRegistry.ts`
|
||||||
|
|
||||||
|
**역할**: POP 컴포넌트 중앙 레지스트리
|
||||||
|
|
||||||
|
**주요 메서드**:
|
||||||
|
```typescript
|
||||||
|
class PopComponentRegistry {
|
||||||
|
static registerComponent(definition: PopComponentDefinition): void
|
||||||
|
static unregisterComponent(id: string): void
|
||||||
|
static getComponent(id: string): PopComponentDefinition | undefined
|
||||||
|
static getComponentByUrl(url: string): PopComponentDefinition | undefined
|
||||||
|
static getAllComponents(): PopComponentDefinition[]
|
||||||
|
static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[]
|
||||||
|
static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[]
|
||||||
|
static searchComponents(query: string): PopComponentDefinition[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**카테고리**:
|
||||||
|
```typescript
|
||||||
|
type PopComponentCategory =
|
||||||
|
| "display" // 데이터 표시 (카드, 리스트, 배지)
|
||||||
|
| "input" // 입력 (스캐너, 터치 입력)
|
||||||
|
| "action" // 액션 (버튼, 스와이프)
|
||||||
|
| "layout" // 레이아웃 (컨테이너, 그리드)
|
||||||
|
| "feedback"; // 피드백 (토스트, 로딩)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `lib/schemas/popComponentConfig.ts`
|
||||||
|
|
||||||
|
**역할**: POP 컴포넌트 설정 스키마 (Zod 기반)
|
||||||
|
|
||||||
|
**제공 내용**:
|
||||||
|
- 컴포넌트별 기본값 (`popCardListDefaults`, `popTouchButtonDefaults` 등)
|
||||||
|
- 컴포넌트별 Zod 스키마 (`popCardListOverridesSchema` 등)
|
||||||
|
- URL → 기본값/스키마 조회 함수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 버전별 레이아웃 시스템
|
||||||
|
|
||||||
|
### v1.0 (deprecated)
|
||||||
|
|
||||||
|
- 단일 모드
|
||||||
|
- 섹션 중첩 구조
|
||||||
|
- CSS Grid
|
||||||
|
|
||||||
|
### v2.0 (deprecated)
|
||||||
|
|
||||||
|
- 4개 모드 (태블릿/모바일 x 가로/세로)
|
||||||
|
- 섹션 + 컴포넌트 분리
|
||||||
|
- CSS Grid
|
||||||
|
|
||||||
|
### v3.0 (현재 기본)
|
||||||
|
|
||||||
|
- 4개 모드
|
||||||
|
- **섹션 제거**, 컴포넌트 직접 배치
|
||||||
|
- CSS Grid
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopLayoutDataV3 {
|
||||||
|
version: "pop-3.0";
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: { componentPositions: Record<string, GridPosition> };
|
||||||
|
tablet_portrait: { componentPositions: Record<string, GridPosition> };
|
||||||
|
mobile_landscape: { componentPositions: Record<string, GridPosition> };
|
||||||
|
mobile_portrait: { componentPositions: Record<string, GridPosition> };
|
||||||
|
};
|
||||||
|
components: Record<string, PopComponentDefinition>;
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
settings: PopGlobalSettings;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### v4.0 (신규, 권장)
|
||||||
|
|
||||||
|
- **단일 소스** (1번 설계 → 모든 화면 자동 적응)
|
||||||
|
- **제약 기반** (fixed/fill/hug)
|
||||||
|
- **Flexbox** 렌더링
|
||||||
|
- **반응형 규칙** (breakpoint)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopLayoutDataV4 {
|
||||||
|
version: "pop-4.0";
|
||||||
|
root: PopContainerV4; // 루트 컨테이너 (스택)
|
||||||
|
components: Record<string, PopComponentDefinitionV4>;
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
settings: PopGlobalSettingsV4;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopContainerV4 {
|
||||||
|
id: string;
|
||||||
|
type: "stack";
|
||||||
|
direction: "horizontal" | "vertical";
|
||||||
|
wrap: boolean;
|
||||||
|
gap: number;
|
||||||
|
alignItems: "start" | "center" | "end" | "stretch";
|
||||||
|
justifyContent: "start" | "center" | "end" | "space-between";
|
||||||
|
padding?: number;
|
||||||
|
responsive?: PopResponsiveRuleV4[]; // 반응형 규칙
|
||||||
|
children: (string | PopContainerV4)[]; // 컴포넌트 ID 또는 중첩 컨테이너
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopSizeConstraintV4 {
|
||||||
|
width: "fixed" | "fill" | "hug";
|
||||||
|
height: "fixed" | "fill" | "hug";
|
||||||
|
fixedWidth?: number;
|
||||||
|
fixedHeight?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 버전 비교표
|
||||||
|
|
||||||
|
| 항목 | v3 | v4 |
|
||||||
|
|------|----|----|
|
||||||
|
| 설계 횟수 | 4번 (모드별) | 1번 |
|
||||||
|
| 위치 지정 | col, row, colSpan, rowSpan | 제약 (fill/fixed/hug) |
|
||||||
|
| 렌더링 | CSS Grid | Flexbox |
|
||||||
|
| 반응형 | 수동 (모드 전환) | 자동 (breakpoint 규칙) |
|
||||||
|
| 복잡도 | 높음 | 낮음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 데이터 흐름
|
||||||
|
|
||||||
|
### 화면 로드 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[사용자 접속]
|
||||||
|
↓
|
||||||
|
[/pop/screens/:screenId]
|
||||||
|
↓
|
||||||
|
[screenApi.getLayoutPop(screenId)]
|
||||||
|
↓
|
||||||
|
[레이아웃 버전 감지]
|
||||||
|
├── v4 → PopFlexRenderer
|
||||||
|
├── v3 → PopLayoutRenderer
|
||||||
|
└── v1/v2 → ensureV3Layout() → v3로 변환
|
||||||
|
```
|
||||||
|
|
||||||
|
### 디자이너 저장 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[사용자 편집]
|
||||||
|
↓
|
||||||
|
[hasChanges = true]
|
||||||
|
↓
|
||||||
|
[저장 버튼 클릭]
|
||||||
|
↓
|
||||||
|
[screenApi.saveLayoutPop(screenId, layoutV3 | layoutV4)]
|
||||||
|
↓
|
||||||
|
[hasChanges = false]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컴포넌트 드래그 앤 드롭 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[PopPanel의 컴포넌트 드래그]
|
||||||
|
↓
|
||||||
|
[DragItemComponent { type: "component", componentType: "pop-button" }]
|
||||||
|
↓
|
||||||
|
[캔버스 Drop 감지]
|
||||||
|
↓
|
||||||
|
[v3: handleDropComponentV3(type, gridPosition)]
|
||||||
|
[v4: handleDropComponentV4(type, containerId)]
|
||||||
|
↓
|
||||||
|
[레이아웃 상태 업데이트]
|
||||||
|
↓
|
||||||
|
[hasChanges = true]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
- [PLAN.md](./PLAN.md) - 개발 계획 및 로드맵
|
||||||
|
- [components-spec.md](./components-spec.md) - 컴포넌트 상세 스펙
|
||||||
|
- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 문서는 POP 화면 시스템의 구조를 이해하고 유지보수하기 위한 참조용으로 작성되었습니다.*
|
||||||
|
|
@ -6,15 +6,155 @@
|
||||||
|
|
||||||
## [미출시]
|
## [미출시]
|
||||||
|
|
||||||
- v4 디자이너 UI 연결
|
- Phase 2: 모드별 오버라이드 기능
|
||||||
|
- Phase 3: 컴포넌트 표시/숨김
|
||||||
|
- Phase 4: 순서 오버라이드
|
||||||
- Tier 2, 3 컴포넌트
|
- Tier 2, 3 컴포넌트
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [2026-02-04]
|
## [2026-02-04] Flexbox 가로 배치 + Spacer + Undo/Redo 개선
|
||||||
|
|
||||||
### 오늘 목표
|
### Added
|
||||||
POP 화면을 만들 수 있는 환경 완성 (타입 + 렌더러 + 기본 컴포넌트)
|
- **Spacer 컴포넌트** (`pop-spacer`)
|
||||||
|
- 빈 공간을 차지하여 레이아웃 정렬에 사용
|
||||||
|
- 기본 크기: `width: fill`, `height: 48px`
|
||||||
|
- 디자인 모드에서 점선 배경으로 표시
|
||||||
|
- 실제 모드에서는 투명 (공간만 차지)
|
||||||
|
|
||||||
|
- **컴포넌트 순서 변경 (드래그 앤 드롭)**
|
||||||
|
- 같은 컨테이너 내에서 컴포넌트 순서 변경 가능
|
||||||
|
- 드래그 중인 컴포넌트는 반투명하게 표시
|
||||||
|
- 드롭 위치는 파란색 테두리로 표시
|
||||||
|
- `handleReorderComponentV4` 핸들러 추가
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **기본 레이아웃 방향 변경** (Flexbox 가로 배치)
|
||||||
|
- `direction: "vertical"` → `direction: "horizontal"`
|
||||||
|
- `wrap: false` → `wrap: true` (자동 줄바꿈)
|
||||||
|
- `alignItems: "stretch"` → `alignItems: "start"`
|
||||||
|
- 컴포넌트가 가로로 나열되고, 공간 부족 시 다음 줄로 이동
|
||||||
|
|
||||||
|
- **컴포넌트 기본 크기 타입별 설정**
|
||||||
|
- 필드: 200x48px (fixed)
|
||||||
|
- 버튼: 120x48px (fixed)
|
||||||
|
- 리스트: fill x 200px
|
||||||
|
- 인디케이터: 120x80px (fixed)
|
||||||
|
- 스캐너: 200x48px (fixed)
|
||||||
|
- 숫자패드: 200x280px (fixed)
|
||||||
|
- Spacer: fill x 48px
|
||||||
|
|
||||||
|
- **Undo/Redo 방식 개선** (데스크탑 모드와 동일)
|
||||||
|
- `useLayoutHistory` 훅 제거
|
||||||
|
- 별도 `history[]`, `historyIndex` 상태로 관리
|
||||||
|
- `saveToHistoryV4()` 함수로 명시적 히스토리 저장
|
||||||
|
- 컴포넌트 추가/삭제/수정/순서변경 시 히스토리 저장
|
||||||
|
|
||||||
|
- **디바이스 스크린 스크롤**
|
||||||
|
- `overflow: auto` 추가 (컴포넌트가 넘치면 스크롤)
|
||||||
|
- `height` → `minHeight` 변경 (컨텐츠에 따라 높이 증가)
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
```
|
||||||
|
업계 표준 레이아웃 방식 (Figma, Webflow, FlutterFlow):
|
||||||
|
1. Flexbox 기반 Row/Column 배치
|
||||||
|
2. 크기 제어: Fill / Fixed / Hug
|
||||||
|
3. Spacer 컴포넌트로 정렬 조정
|
||||||
|
4. 화면 크기별 조건 분기 (반응형)
|
||||||
|
|
||||||
|
사용 예시:
|
||||||
|
[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로
|
||||||
|
[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-04] 드래그 리사이즈 + Undo/Redo 기능
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **useLayoutHistory.ts** - Undo/Redo 히스토리 훅
|
||||||
|
- 최대 50개 히스토리 저장
|
||||||
|
- `undo()`, `redo()`, `canUndo`, `canRedo`
|
||||||
|
- `reset()` - 새 레이아웃 로드 시 히스토리 초기화
|
||||||
|
|
||||||
|
- **드래그 리사이즈 핸들** (PopFlexRenderer)
|
||||||
|
- 오른쪽 핸들: 너비 조정 (cursor: ew-resize)
|
||||||
|
- 아래쪽 핸들: 높이 조정 (cursor: ns-resize)
|
||||||
|
- 오른쪽 아래 핸들: 너비+높이 동시 조정 (cursor: nwse-resize)
|
||||||
|
- 선택된 컴포넌트에만 표시
|
||||||
|
- 최소 크기 보장 (너비 48px, 높이 touchTargetMin)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **PopDesigner.tsx**
|
||||||
|
- `useLayoutHistory` 훅 통합 (v3, v4 각각 독립적)
|
||||||
|
- Undo/Redo 버튼 추가 (툴바 오른쪽)
|
||||||
|
- 단축키 등록:
|
||||||
|
- `Ctrl+Z` / `Cmd+Z`: 실행 취소
|
||||||
|
- `Ctrl+Shift+Z` / `Cmd+Shift+Z` / `Ctrl+Y`: 다시 실행
|
||||||
|
- `handleResizeComponentV4` 핸들러 추가
|
||||||
|
|
||||||
|
- **PopCanvasV4.tsx**
|
||||||
|
- `onResizeComponent` prop 추가
|
||||||
|
- PopFlexRenderer에 전달
|
||||||
|
|
||||||
|
- **PopFlexRenderer.tsx**
|
||||||
|
- `onComponentResize` prop 추가
|
||||||
|
- ComponentRendererV4에 리사이즈 핸들 추가
|
||||||
|
- 드래그 이벤트 처리 (mousemove, mouseup)
|
||||||
|
|
||||||
|
### 단축키 목록
|
||||||
|
| 단축키 | 기능 |
|
||||||
|
|--------|------|
|
||||||
|
| `Delete` / `Backspace` | 선택된 컴포넌트 삭제 |
|
||||||
|
| `Ctrl+Z` / `Cmd+Z` | 실행 취소 (Undo) |
|
||||||
|
| `Ctrl+Shift+Z` / `Ctrl+Y` | 다시 실행 (Redo) |
|
||||||
|
| `Space` + 드래그 | 캔버스 패닝 |
|
||||||
|
| `Ctrl` + 휠 | 줌 인/아웃 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-04] v4 통합 설계 모드 Phase 1 완료
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
v4를 기본 레이아웃 모드로 통합하고, 새 화면은 자동으로 v4로 시작
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **ComponentPaletteV4.tsx** - v4 전용 컴포넌트 팔레트
|
||||||
|
- 6개 컴포넌트 (필드, 버튼, 리스트, 인디케이터, 스캐너, 숫자패드)
|
||||||
|
- 드래그 앤 드롭 지원
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **PopDesigner.tsx** - v3/v4 통합 디자이너로 리팩토링
|
||||||
|
- v3/v4 탭 제거 (자동 판별)
|
||||||
|
- 새 화면 → v4로 시작
|
||||||
|
- 기존 v3 화면 → v3로 로드 (하위 호환)
|
||||||
|
- 빈 레이아웃 → v4로 시작 (컴포넌트 유무로 판별)
|
||||||
|
- 레이아웃 버전 텍스트 표시 ("자동 레이아웃 (v4)" / "4모드 레이아웃 (v3)")
|
||||||
|
|
||||||
|
- **PopCanvasV4.tsx** - 4개 프리셋으로 변경
|
||||||
|
- 기존: [모바일] [태블릿] [데스크톱]
|
||||||
|
- 변경: [모바일↕] [모바일↔] [태블릿↕] [태블릿↔]
|
||||||
|
- 기본 프리셋: 태블릿 가로 (1024x768)
|
||||||
|
- 슬라이더 범위: 320~1200px
|
||||||
|
- 비율 유지: 슬라이더 조절 시 높이도 비율에 맞게 자동 조정
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 새 화면이 v3로 열리는 문제
|
||||||
|
- 원인: 백엔드가 빈 v2 레이아웃 반환 (version 필드 있음)
|
||||||
|
- 해결: 컴포넌트 유무로 빈 레이아웃 판별 → v4로 시작
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
```
|
||||||
|
레이아웃 로드 로직:
|
||||||
|
1. version 필드 확인
|
||||||
|
2. components 존재 여부 확인
|
||||||
|
3. version 있고 components 있음 → 해당 버전으로 로드
|
||||||
|
4. version 없거나 components 없음 → v4로 새로 시작
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-04] v4 타입 및 렌더러
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **v4 타입 정의** (간결 버전)
|
- **v4 타입 정의** (간결 버전)
|
||||||
|
|
@ -26,10 +166,21 @@ POP 화면을 만들 수 있는 환경 완성 (타입 + 렌더러 + 기본 컴
|
||||||
- `PopGlobalSettingsV4` - 전역 설정
|
- `PopGlobalSettingsV4` - 전역 설정
|
||||||
- `createEmptyPopLayoutV4()` - 생성 함수
|
- `createEmptyPopLayoutV4()` - 생성 함수
|
||||||
- `isV4Layout()` - 타입 가드
|
- `isV4Layout()` - 타입 가드
|
||||||
|
- CRUD 함수들 (add, remove, update, find)
|
||||||
|
|
||||||
### 진행중
|
- **PopFlexRenderer.tsx** - v4 Flexbox 렌더러
|
||||||
- v4 렌더러 (`PopFlexRenderer`)
|
- 컨테이너 재귀 렌더링
|
||||||
- 기본 컴포넌트 (PopButton, PopInput, PopLabel)
|
- 반응형 규칙 적용
|
||||||
|
- 크기 제약 → CSS 변환
|
||||||
|
|
||||||
|
- **ComponentEditorPanelV4.tsx** - v4 속성 편집 패널
|
||||||
|
- 크기 제약 편집 UI
|
||||||
|
- 컨테이너 설정 UI
|
||||||
|
|
||||||
|
- **PopCanvasV4.tsx** - v4 전용 캔버스
|
||||||
|
- 뷰포트 프리셋
|
||||||
|
- 너비 슬라이더
|
||||||
|
- 줌/패닝
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,925 @@
|
||||||
|
# POP 파일 상세 목록
|
||||||
|
|
||||||
|
**최종 업데이트: 2026-02-04**
|
||||||
|
|
||||||
|
이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [App Router 파일](#1-app-router-파일)
|
||||||
|
2. [Designer 파일](#2-designer-파일)
|
||||||
|
3. [Panels 파일](#3-panels-파일)
|
||||||
|
4. [Renderers 파일](#4-renderers-파일)
|
||||||
|
5. [Types 파일](#5-types-파일)
|
||||||
|
6. [Management 파일](#6-management-파일)
|
||||||
|
7. [Dashboard 파일](#7-dashboard-파일)
|
||||||
|
8. [Library 파일](#8-library-파일)
|
||||||
|
9. [루트 컴포넌트 파일](#9-루트-컴포넌트-파일)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. App Router 파일
|
||||||
|
|
||||||
|
### `frontend/app/(pop)/layout.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 전용 레이아웃 래퍼 |
|
||||||
|
| 라우트 그룹 | `(pop)` - URL에 포함되지 않음 |
|
||||||
|
| 특징 | 데스크톱과 분리된 터치 최적화 환경 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/app/(pop)/pop/page.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 메인 대시보드 |
|
||||||
|
| 경로 | `/pop` |
|
||||||
|
| 사용 컴포넌트 | `PopDashboard` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 개별 POP 화면 뷰어 |
|
||||||
|
| 경로 | `/pop/screens/:screenId` |
|
||||||
|
| 라인 수 | 468줄 |
|
||||||
|
|
||||||
|
**핵심 코드 구조**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 상태
|
||||||
|
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
|
||||||
|
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
|
||||||
|
|
||||||
|
// 레이아웃 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||||
|
|
||||||
|
if (isPopLayoutV4(popLayout)) {
|
||||||
|
setPopLayoutV4(popLayout);
|
||||||
|
} else if (isPopLayout(popLayout)) {
|
||||||
|
const v3Layout = ensureV3Layout(popLayout);
|
||||||
|
setPopLayoutV3(v3Layout);
|
||||||
|
}
|
||||||
|
}, [screenId]);
|
||||||
|
|
||||||
|
// 렌더링 분기
|
||||||
|
{popLayoutV4 ? (
|
||||||
|
<PopFlexRenderer layout={popLayoutV4} viewportWidth={...} />
|
||||||
|
) : popLayoutV3 ? (
|
||||||
|
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} />
|
||||||
|
) : (
|
||||||
|
// 빈 화면
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**제공 기능**:
|
||||||
|
- 반응형 모드 감지 (useResponsiveModeWithOverride)
|
||||||
|
- 프리뷰 모드 (`?preview=true`)
|
||||||
|
- 디바이스/방향 수동 전환 (프리뷰 모드)
|
||||||
|
- v1/v2/v3/v4 레이아웃 자동 감지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/app/(pop)/pop/test-v4/page.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | v4 렌더러 테스트 페이지 |
|
||||||
|
| 경로 | `/pop/test-v4` |
|
||||||
|
| 라인 수 | 150줄 |
|
||||||
|
|
||||||
|
**핵심 코드 구조**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default function TestV4Page() {
|
||||||
|
const [layout, setLayout] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
|
||||||
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
|
||||||
|
const [idCounter, setIdCounter] = useState(1);
|
||||||
|
|
||||||
|
// 컴포넌트 CRUD
|
||||||
|
const handleDropComponent = useCallback(...);
|
||||||
|
const handleDeleteComponent = useCallback(...);
|
||||||
|
const handleUpdateComponent = useCallback(...);
|
||||||
|
const handleUpdateContainer = useCallback(...);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
{/* 3-column 레이아웃 */}
|
||||||
|
<PopPanel />
|
||||||
|
<PopCanvasV4 ... />
|
||||||
|
<ComponentEditorPanelV4 ... />
|
||||||
|
</DndProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/app/(pop)/pop/work/page.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 작업 화면 (샘플) |
|
||||||
|
| 경로 | `/pop/work` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Designer 파일
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/PopDesigner.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 화면 디자이너 메인 |
|
||||||
|
| 라인 수 | 524줄 |
|
||||||
|
| 의존성 | react-dnd, ResizablePanelGroup |
|
||||||
|
|
||||||
|
**핵심 Props**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopDesignerProps {
|
||||||
|
selectedScreen: ScreenDefinition;
|
||||||
|
onBackToList: () => void;
|
||||||
|
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**상태 관리**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 레이아웃 모드
|
||||||
|
const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3");
|
||||||
|
|
||||||
|
// v3 레이아웃
|
||||||
|
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
|
||||||
|
|
||||||
|
// v4 레이아웃
|
||||||
|
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
|
||||||
|
|
||||||
|
// 선택 상태
|
||||||
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// UI 상태
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
// v3용 상태
|
||||||
|
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
||||||
|
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
||||||
|
```
|
||||||
|
|
||||||
|
**주요 핸들러**:
|
||||||
|
|
||||||
|
| 핸들러 | 역할 |
|
||||||
|
|--------|------|
|
||||||
|
| `handleDropComponentV3` | v3 컴포넌트 드롭 |
|
||||||
|
| `handleDropComponentV4` | v4 컴포넌트 드롭 |
|
||||||
|
| `handleUpdateComponentDefinitionV3` | v3 컴포넌트 정의 수정 |
|
||||||
|
| `handleUpdateComponentV4` | v4 컴포넌트 수정 |
|
||||||
|
| `handleUpdateContainerV4` | v4 컨테이너 수정 |
|
||||||
|
| `handleDeleteComponentV3` | v3 컴포넌트 삭제 |
|
||||||
|
| `handleDeleteComponentV4` | v4 컴포넌트 삭제 |
|
||||||
|
| `handleSave` | 레이아웃 저장 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/PopCanvas.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | v3 CSS Grid 기반 캔버스 |
|
||||||
|
| 렌더링 | CSS Grid |
|
||||||
|
| 모드 | 4개 (태블릿/모바일 x 가로/세로) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/PopCanvasV4.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | v4 Flexbox 기반 캔버스 |
|
||||||
|
| 라인 수 | 309줄 |
|
||||||
|
| 렌더링 | Flexbox (via PopFlexRenderer) |
|
||||||
|
|
||||||
|
**핵심 Props**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopCanvasV4Props {
|
||||||
|
layout: PopLayoutDataV4;
|
||||||
|
selectedComponentId: string | null;
|
||||||
|
selectedContainerId: string | null;
|
||||||
|
onSelectComponent: (id: string | null) => void;
|
||||||
|
onSelectContainer: (id: string | null) => void;
|
||||||
|
onDropComponent: (type: PopComponentType, containerId: string) => void;
|
||||||
|
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV4>) => void;
|
||||||
|
onUpdateContainer: (containerId: string, updates: Partial<PopContainerV4>) => void;
|
||||||
|
onDeleteComponent: (componentId: string) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**뷰포트 프리셋**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const VIEWPORT_PRESETS = [
|
||||||
|
{ id: "mobile", label: "모바일", width: 375, height: 667, icon: Smartphone },
|
||||||
|
{ id: "tablet", label: "태블릿", width: 768, height: 1024, icon: Tablet },
|
||||||
|
{ id: "desktop", label: "데스크톱", width: 1024, height: 768, icon: Monitor },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**제공 기능**:
|
||||||
|
- 뷰포트 프리셋 전환
|
||||||
|
- 너비 슬라이더 (320px ~ 1440px)
|
||||||
|
- 줌 컨트롤 (30% ~ 150%)
|
||||||
|
- 패닝 (Space + 드래그 또는 휠 클릭)
|
||||||
|
- 컴포넌트 드롭
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { default as PopDesigner } from "./PopDesigner";
|
||||||
|
export { PopCanvas } from "./PopCanvas";
|
||||||
|
export { PopCanvasV4 } from "./PopCanvasV4";
|
||||||
|
export * from "./panels";
|
||||||
|
export * from "./renderers";
|
||||||
|
export * from "./types";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Panels 파일
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/panels/PopPanel.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 왼쪽 패널 (컴포넌트 팔레트 + 편집) |
|
||||||
|
| 라인 수 | 369줄 |
|
||||||
|
|
||||||
|
**탭 구성**:
|
||||||
|
1. `components` - 컴포넌트 팔레트
|
||||||
|
2. `edit` - 선택된 컴포넌트 편집
|
||||||
|
|
||||||
|
**컴포넌트 팔레트**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const COMPONENT_PALETTE = [
|
||||||
|
{ type: "pop-field", label: "필드", icon: Type, description: "텍스트, 숫자 등 데이터 입력" },
|
||||||
|
{ type: "pop-button", label: "버튼", icon: MousePointer, description: "저장, 삭제 등 액션 실행" },
|
||||||
|
{ type: "pop-list", label: "리스트", icon: List, description: "데이터 목록 (카드 템플릿 지원)" },
|
||||||
|
{ type: "pop-indicator", label: "인디케이터", icon: Activity, description: "KPI, 상태 표시" },
|
||||||
|
{ type: "pop-scanner", label: "스캐너", icon: ScanLine, description: "바코드/QR 스캔" },
|
||||||
|
{ type: "pop-numpad", label: "숫자패드", icon: Calculator, description: "숫자 입력 전용" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**내보내기 (exports)**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const DND_ITEM_TYPES = { COMPONENT: "component" };
|
||||||
|
export interface DragItemComponent { ... }
|
||||||
|
export function PopPanel({ ... }: PopPanelProps) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | v3 컴포넌트 편집 패널 |
|
||||||
|
| 용도 | PopPanel 내부에서 사용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | v4 오른쪽 속성 패널 |
|
||||||
|
| 라인 수 | 609줄 |
|
||||||
|
|
||||||
|
**핵심 Props**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ComponentEditorPanelV4Props {
|
||||||
|
component: PopComponentDefinitionV4 | null;
|
||||||
|
container: PopContainerV4 | null;
|
||||||
|
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
||||||
|
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3개 탭**:
|
||||||
|
|
||||||
|
| 탭 | 아이콘 | 내용 |
|
||||||
|
|----|--------|------|
|
||||||
|
| `size` | Maximize2 | 크기 제약 (fixed/fill/hug) |
|
||||||
|
| `settings` | Settings | 라벨, 타입별 설정 |
|
||||||
|
| `data` | Database | 데이터 바인딩 (미구현) |
|
||||||
|
|
||||||
|
**내부 컴포넌트**:
|
||||||
|
|
||||||
|
| 컴포넌트 | 역할 |
|
||||||
|
|----------|------|
|
||||||
|
| `SizeConstraintForm` | 너비/높이 제약 편집 |
|
||||||
|
| `SizeButton` | fixed/fill/hug 선택 버튼 |
|
||||||
|
| `ContainerSettingsForm` | 컨테이너 방향/정렬/간격 편집 |
|
||||||
|
| `ComponentSettingsForm` | 라벨 편집 |
|
||||||
|
| `DataBindingPlaceholder` | 데이터 바인딩 플레이스홀더 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/panels/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { PopPanel, DND_ITEM_TYPES } from "./PopPanel";
|
||||||
|
export type { DragItemComponent } from "./PopPanel";
|
||||||
|
export { ComponentEditorPanel } from "./ComponentEditorPanel";
|
||||||
|
export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Renderers 파일
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | v3 레이아웃 CSS Grid 렌더러 |
|
||||||
|
| 입력 | PopLayoutDataV3, modeKey |
|
||||||
|
|
||||||
|
**내보내기**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function PopLayoutRenderer({ ... }) { ... }
|
||||||
|
export function hasBaseLayout(layout: PopLayoutDataV3): boolean { ... }
|
||||||
|
export function getEffectiveModeLayout(layout: PopLayoutDataV3, modeKey: PopLayoutModeKey) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | v4 레이아웃 Flexbox 렌더러 |
|
||||||
|
| 라인 수 | 498줄 |
|
||||||
|
| 입력 | PopLayoutDataV4, viewportWidth |
|
||||||
|
|
||||||
|
**핵심 Props**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopFlexRendererProps {
|
||||||
|
layout: PopLayoutDataV4;
|
||||||
|
viewportWidth: number;
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
selectedComponentId?: string | null;
|
||||||
|
onComponentClick?: (componentId: string) => void;
|
||||||
|
onContainerClick?: (containerId: string) => void;
|
||||||
|
onBackgroundClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**내부 컴포넌트**:
|
||||||
|
|
||||||
|
| 컴포넌트 | 역할 |
|
||||||
|
|----------|------|
|
||||||
|
| `ContainerRenderer` | 컨테이너 재귀 렌더링 |
|
||||||
|
| `ComponentRendererV4` | v4 컴포넌트 렌더링 |
|
||||||
|
|
||||||
|
**핵심 함수**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 반응형 규칙 적용
|
||||||
|
function applyResponsiveRules(container: PopContainerV4, viewportWidth: number): PopContainerV4
|
||||||
|
|
||||||
|
// 크기 제약 → CSS 스타일
|
||||||
|
function calculateSizeStyle(size: PopSizeConstraintV4, settings: PopGlobalSettingsV4): React.CSSProperties
|
||||||
|
|
||||||
|
// 정렬 값 변환
|
||||||
|
function mapAlignment(value: string): React.CSSProperties["alignItems"]
|
||||||
|
function mapJustify(value: string): React.CSSProperties["justifyContent"]
|
||||||
|
|
||||||
|
// 컴포넌트 내용 렌더링
|
||||||
|
function renderComponentContent(component: PopComponentDefinitionV4, ...): React.ReactNode
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/renderers/ComponentRenderer.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 개별 컴포넌트 렌더러 (디자인 모드용) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/renderers/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { PopLayoutRenderer, hasBaseLayout, getEffectiveModeLayout } from "./PopLayoutRenderer";
|
||||||
|
export { ComponentRenderer } from "./ComponentRenderer";
|
||||||
|
export { PopFlexRenderer } from "./PopFlexRenderer";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Types 파일
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/types/pop-layout.ts`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 레이아웃 전체 타입 시스템 |
|
||||||
|
| 라인 수 | 1442줄 |
|
||||||
|
|
||||||
|
**주요 타입** (v4):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// v4 레이아웃
|
||||||
|
interface PopLayoutDataV4 {
|
||||||
|
version: "pop-4.0";
|
||||||
|
root: PopContainerV4;
|
||||||
|
components: Record<string, PopComponentDefinitionV4>;
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
settings: PopGlobalSettingsV4;
|
||||||
|
metadata?: PopLayoutMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4 컨테이너
|
||||||
|
interface PopContainerV4 {
|
||||||
|
id: string;
|
||||||
|
type: "stack";
|
||||||
|
direction: "horizontal" | "vertical";
|
||||||
|
wrap: boolean;
|
||||||
|
gap: number;
|
||||||
|
alignItems: "start" | "center" | "end" | "stretch";
|
||||||
|
justifyContent: "start" | "center" | "end" | "space-between";
|
||||||
|
padding?: number;
|
||||||
|
responsive?: PopResponsiveRuleV4[];
|
||||||
|
children: (string | PopContainerV4)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4 크기 제약
|
||||||
|
interface PopSizeConstraintV4 {
|
||||||
|
width: "fixed" | "fill" | "hug";
|
||||||
|
height: "fixed" | "fill" | "hug";
|
||||||
|
fixedWidth?: number;
|
||||||
|
fixedHeight?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4 반응형 규칙
|
||||||
|
interface PopResponsiveRuleV4 {
|
||||||
|
breakpoint: number;
|
||||||
|
direction?: "horizontal" | "vertical";
|
||||||
|
gap?: number;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**주요 타입** (v3):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// v3 레이아웃
|
||||||
|
interface PopLayoutDataV3 {
|
||||||
|
version: "pop-3.0";
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: PopModeLayoutV3;
|
||||||
|
tablet_portrait: PopModeLayoutV3;
|
||||||
|
mobile_landscape: PopModeLayoutV3;
|
||||||
|
mobile_portrait: PopModeLayoutV3;
|
||||||
|
};
|
||||||
|
components: Record<string, PopComponentDefinition>;
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
settings: PopGlobalSettings;
|
||||||
|
metadata?: PopLayoutMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3 모드별 레이아웃
|
||||||
|
interface PopModeLayoutV3 {
|
||||||
|
componentPositions: Record<string, GridPosition>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그리드 위치
|
||||||
|
interface GridPosition {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
colSpan: number;
|
||||||
|
rowSpan: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**주요 함수**:
|
||||||
|
|
||||||
|
| 함수 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `createEmptyPopLayoutV4()` | 빈 v4 레이아웃 생성 |
|
||||||
|
| `createEmptyPopLayoutV3()` | 빈 v3 레이아웃 생성 |
|
||||||
|
| `addComponentToV4Layout()` | v4에 컴포넌트 추가 |
|
||||||
|
| `removeComponentFromV4Layout()` | v4에서 컴포넌트 삭제 |
|
||||||
|
| `updateComponentInV4Layout()` | v4 컴포넌트 수정 |
|
||||||
|
| `updateContainerV4()` | v4 컨테이너 수정 |
|
||||||
|
| `findContainerV4()` | v4 컨테이너 찾기 |
|
||||||
|
| `addComponentToV3Layout()` | v3에 컴포넌트 추가 |
|
||||||
|
| `removeComponentFromV3Layout()` | v3에서 컴포넌트 삭제 |
|
||||||
|
| `updateComponentPositionInModeV3()` | v3 특정 모드 위치 수정 |
|
||||||
|
| `isV4Layout()` | v4 타입 가드 |
|
||||||
|
| `isV3Layout()` | v3 타입 가드 |
|
||||||
|
| `ensureV3Layout()` | v1/v2/v3 → v3 변환 |
|
||||||
|
| `migrateV2ToV3()` | v2 → v3 마이그레이션 |
|
||||||
|
| `migrateV1ToV3()` | v1 → v3 마이그레이션 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/types/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export * from "./pop-layout";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Management 파일
|
||||||
|
|
||||||
|
### `frontend/components/pop/management/PopCategoryTree.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 화면 카테고리 트리 |
|
||||||
|
| 기능 | 그룹 추가/수정/삭제, 화면 목록 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/management/PopScreenSettingModal.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 화면 설정 모달 |
|
||||||
|
| 기능 | 화면명, 설명, 그룹 설정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/management/PopScreenPreview.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 화면 미리보기 |
|
||||||
|
| 기능 | 썸네일, 기본 정보 표시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/management/PopScreenFlowView.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 화면 간 플로우 시각화 |
|
||||||
|
| 기능 | 화면 연결 관계 표시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/management/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { PopCategoryTree } from "./PopCategoryTree";
|
||||||
|
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
||||||
|
export { PopScreenPreview } from "./PopScreenPreview";
|
||||||
|
export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dashboard 파일
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/PopDashboard.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 대시보드 메인 |
|
||||||
|
| 구성 | 헤더, KPI, 메뉴그리드, 공지, 푸터 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/DashboardHeader.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 상단 헤더 |
|
||||||
|
| 표시 | 로고, 시간, 사용자 정보 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/DashboardFooter.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 하단 푸터 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/MenuGrid.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 메뉴 그리드 |
|
||||||
|
| 스타일 | 앱 아이콘 형태 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/KpiBar.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | KPI 요약 바 |
|
||||||
|
| 표시 | 핵심 지표 수치 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/NoticeBanner.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 공지 배너 |
|
||||||
|
| 스타일 | 슬라이드 배너 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/NoticeList.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 공지 목록 |
|
||||||
|
| 스타일 | 리스트 형태 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/ActivityList.tsx`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 최근 활동 목록 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { PopDashboard } from "./PopDashboard";
|
||||||
|
export { DashboardHeader } from "./DashboardHeader";
|
||||||
|
export { DashboardFooter } from "./DashboardFooter";
|
||||||
|
export { MenuGrid } from "./MenuGrid";
|
||||||
|
export { KpiBar } from "./KpiBar";
|
||||||
|
export { NoticeBanner } from "./NoticeBanner";
|
||||||
|
export { NoticeList } from "./NoticeList";
|
||||||
|
export { ActivityList } from "./ActivityList";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/types.ts`
|
||||||
|
|
||||||
|
대시보드 관련 타입 정의
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/data.ts`
|
||||||
|
|
||||||
|
대시보드 샘플/목업 데이터
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/dashboard/dashboard.css`
|
||||||
|
|
||||||
|
대시보드 전용 스타일
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Library 파일
|
||||||
|
|
||||||
|
### `frontend/lib/api/popScreenGroup.ts`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 화면 그룹 API 클라이언트 |
|
||||||
|
| 라인 수 | 183줄 |
|
||||||
|
|
||||||
|
**타입**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopScreenGroup extends ScreenGroup {
|
||||||
|
children?: PopScreenGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatePopScreenGroupRequest {
|
||||||
|
group_name: string;
|
||||||
|
group_code: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
display_order?: number;
|
||||||
|
parent_group_id?: number | null;
|
||||||
|
target_company_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatePopScreenGroupRequest {
|
||||||
|
group_name?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
display_order?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 함수**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function getPopScreenGroups(searchTerm?: string): Promise<PopScreenGroup[]>
|
||||||
|
async function createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...>
|
||||||
|
async function updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...>
|
||||||
|
async function deletePopScreenGroup(id: number): Promise<...>
|
||||||
|
async function ensurePopRootGroup(): Promise<...>
|
||||||
|
```
|
||||||
|
|
||||||
|
**유틸리티**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/lib/registry/PopComponentRegistry.ts`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 컴포넌트 중앙 레지스트리 |
|
||||||
|
| 라인 수 | 268줄 |
|
||||||
|
|
||||||
|
**타입**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopComponentDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: PopComponentCategory;
|
||||||
|
icon?: string;
|
||||||
|
component: React.ComponentType<any>;
|
||||||
|
configPanel?: React.ComponentType<any>;
|
||||||
|
defaultProps?: Record<string, any>;
|
||||||
|
touchOptimized?: boolean;
|
||||||
|
minTouchArea?: number;
|
||||||
|
supportedDevices?: ("mobile" | "tablet")[];
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PopComponentCategory =
|
||||||
|
| "display"
|
||||||
|
| "input"
|
||||||
|
| "action"
|
||||||
|
| "layout"
|
||||||
|
| "feedback";
|
||||||
|
```
|
||||||
|
|
||||||
|
**메서드**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class PopComponentRegistry {
|
||||||
|
static registerComponent(definition: PopComponentDefinition): void
|
||||||
|
static unregisterComponent(id: string): void
|
||||||
|
static getComponent(id: string): PopComponentDefinition | undefined
|
||||||
|
static getComponentByUrl(url: string): PopComponentDefinition | undefined
|
||||||
|
static getAllComponents(): PopComponentDefinition[]
|
||||||
|
static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[]
|
||||||
|
static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[]
|
||||||
|
static searchComponents(query: string): PopComponentDefinition[]
|
||||||
|
static getComponentCount(): number
|
||||||
|
static getStatsByCategory(): Record<PopComponentCategory, number>
|
||||||
|
static addEventListener(callback: (event: PopComponentRegistryEvent) => void): void
|
||||||
|
static removeEventListener(callback: (event: PopComponentRegistryEvent) => void): void
|
||||||
|
static clear(): void
|
||||||
|
static hasComponent(id: string): boolean
|
||||||
|
static debug(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/lib/schemas/popComponentConfig.ts`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | POP 컴포넌트 설정 스키마 |
|
||||||
|
| 라인 수 | 232줄 |
|
||||||
|
| 검증 | Zod 기반 |
|
||||||
|
|
||||||
|
**기본값**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const popCardListDefaults = { ... }
|
||||||
|
const popTouchButtonDefaults = { ... }
|
||||||
|
const popScannerInputDefaults = { ... }
|
||||||
|
const popStatusBadgeDefaults = { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**스키마**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const popCardListOverridesSchema = z.object({ ... })
|
||||||
|
const popTouchButtonOverridesSchema = z.object({ ... })
|
||||||
|
const popScannerInputOverridesSchema = z.object({ ... })
|
||||||
|
const popStatusBadgeOverridesSchema = z.object({ ... })
|
||||||
|
```
|
||||||
|
|
||||||
|
**유틸리티**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getPopComponentUrl(componentType: string): string
|
||||||
|
function getPopComponentDefaults(componentType: string): Record<string, any>
|
||||||
|
function getPopDefaultsByUrl(componentUrl: string): Record<string, any>
|
||||||
|
function parsePopOverridesByUrl(componentUrl: string, overrides: Record<string, any>): Record<string, any>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 루트 컴포넌트 파일
|
||||||
|
|
||||||
|
### `frontend/components/pop/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export * from "./designer";
|
||||||
|
export * from "./management";
|
||||||
|
export * from "./dashboard";
|
||||||
|
// 개별 컴포넌트 export
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/types.ts`
|
||||||
|
|
||||||
|
POP 공통 타입 정의
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/data.ts`
|
||||||
|
|
||||||
|
POP 샘플/목업 데이터
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `frontend/components/pop/styles.css`
|
||||||
|
|
||||||
|
POP 전역 스타일
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 기타 루트 레벨 컴포넌트
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `PopApp.tsx` | POP 앱 셸 |
|
||||||
|
| `PopHeader.tsx` | 공통 헤더 |
|
||||||
|
| `PopBottomNav.tsx` | 하단 네비게이션 |
|
||||||
|
| `PopStatusTabs.tsx` | 상태 탭 |
|
||||||
|
| `PopWorkCard.tsx` | 작업 카드 |
|
||||||
|
| `PopProductionPanel.tsx` | 생산 패널 |
|
||||||
|
| `PopSettingsModal.tsx` | 설정 모달 |
|
||||||
|
| `PopAcceptModal.tsx` | 수락 모달 |
|
||||||
|
| `PopProcessModal.tsx` | 프로세스 모달 |
|
||||||
|
| `PopEquipmentModal.tsx` | 설비 모달 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 수 통계
|
||||||
|
|
||||||
|
| 폴더 | 파일 수 | 설명 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `app/(pop)` | 6 | App Router 페이지 |
|
||||||
|
| `components/pop/designer` | 12 | 디자이너 모듈 |
|
||||||
|
| `components/pop/management` | 5 | 관리 모듈 |
|
||||||
|
| `components/pop/dashboard` | 12 | 대시보드 모듈 |
|
||||||
|
| `components/pop` (루트) | 15 | 루트 컴포넌트 |
|
||||||
|
| `lib` | 3 | 라이브러리 |
|
||||||
|
| **총계** | **53** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다.*
|
||||||
180
popdocs/PLAN.md
180
popdocs/PLAN.md
|
|
@ -2,29 +2,102 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 오늘 목표 (2026-02-04)
|
## 현재 상태 (2026-02-04)
|
||||||
|
|
||||||
**POP 화면을 만들 수 있는 환경 완성**
|
**v4 통합 설계 모드 Phase 1.5 완료 (Flexbox 가로 배치 + Spacer)**
|
||||||
|
|
||||||
### 필요한 것
|
### 완료된 작업
|
||||||
|
|
||||||
1. **v4 타입 정의** - 완료
|
1. **v4 기본 구조** - 완료
|
||||||
2. **v4 렌더러** - Flexbox로 화면에 표시
|
2. **v4 렌더러** - 완료 (PopFlexRenderer)
|
||||||
3. **기본 컴포넌트** - 실제 배치할 요소들
|
3. **v4 디자이너 통합** - 완료
|
||||||
4. **디자이너 UI 연결** - v4 모드로 설계 가능
|
4. **새 화면 v4 기본 적용** - 완료
|
||||||
|
5. **Undo/Redo** - 완료 (데스크탑 모드와 동일 방식)
|
||||||
|
6. **드래그 리사이즈** - 완료
|
||||||
|
7. **Flexbox 가로 배치** - 완료 (업계 표준 방식)
|
||||||
|
8. **Spacer 컴포넌트** - 완료
|
||||||
|
9. **컴포넌트 순서 변경** - 완료 (드래그 앤 드롭)
|
||||||
|
|
||||||
### 작업 순서
|
### 현재 UI 상태
|
||||||
|
|
||||||
```
|
```
|
||||||
[v4 타입] → [렌더러] → [컴포넌트] → [디자이너 연결]
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
완료 진행 대기 대기
|
│ ← 목록 화면명 *변경됨 [↶][↷] 자동 레이아웃 (v4) [저장] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 미리보기: [모바일↕] [모바일↔] [태블릿↕] [태블릿↔(기본)] │
|
||||||
|
│ 너비: [========●====] 1024 x 768 70% [-][+] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ 컴포넌트 │ [필드1] [필드2] [필드3] [필드4] │ 속성 패널 │
|
||||||
|
│ 팔레트 │ [필드5] [Spacer] [Spacer] │ │
|
||||||
|
│ (20%) │ (가로 배치 + 줄바꿈) │ (20%) │
|
||||||
|
│ │ │ │
|
||||||
|
│ - 필드 │ 디바이스 스크린 (스크롤 가능) │ │
|
||||||
|
│ - 버튼 │ │ │
|
||||||
|
│ - 리스트 │ │ │
|
||||||
|
│ - 인디케이터│ │ │
|
||||||
|
│ - 스캐너 │ │ │
|
||||||
|
│ - 숫자패드 │ │ │
|
||||||
|
│ - 스페이서 │ │ │
|
||||||
|
└──────────┴────────────────────────────────────┴─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 현재 진행
|
## 작업 순서
|
||||||
|
|
||||||
### v4 타입 정의 (완료)
|
```
|
||||||
|
[Phase 1] [Phase 2] [Phase 3] [Phase 4]
|
||||||
|
v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 → 순서 오버라이드
|
||||||
|
완료 다음 계획 계획
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: 기본 구조 (완료)
|
||||||
|
|
||||||
|
- [x] v3/v4 탭 제거 (자동 판별)
|
||||||
|
- [x] 새 화면 → v4로 시작
|
||||||
|
- [x] 기존 v3 화면 → v3로 로드 (하위 호환)
|
||||||
|
- [x] 4개 프리셋 버튼 (모바일↕, 모바일↔, 태블릿↕, 태블릿↔)
|
||||||
|
- [x] 슬라이더 유지 (320~1200px)
|
||||||
|
- [x] 기본 프리셋: 태블릿 가로 (1024x768)
|
||||||
|
- [x] ComponentPaletteV4 생성 (v4 전용 팔레트)
|
||||||
|
- [x] 빈 레이아웃도 v4로 시작하도록 로직 수정
|
||||||
|
|
||||||
|
## Phase 1.5: Flexbox 가로 배치 + 기본 기능 (완료)
|
||||||
|
|
||||||
|
- [x] Undo/Redo (Ctrl+Z / Ctrl+Shift+Z)
|
||||||
|
- [x] 드래그 리사이즈 핸들 (오른쪽, 아래, 오른쪽아래)
|
||||||
|
- [x] Flexbox 가로 배치 (`direction: horizontal`, `wrap: true`)
|
||||||
|
- [x] 컴포넌트 타입별 기본 크기 설정
|
||||||
|
- [x] Spacer 컴포넌트 (`pop-spacer`) - 정렬용 빈 공간
|
||||||
|
- [x] 컴포넌트 순서 변경 (드래그 앤 드롭)
|
||||||
|
- [x] 디바이스 스크린 스크롤 (무한 스크롤)
|
||||||
|
|
||||||
|
## Phase 2: 오버라이드 기능 (다음)
|
||||||
|
|
||||||
|
- [ ] ModeOverride 데이터 구조 추가
|
||||||
|
- [ ] 편집 감지 → 자동 오버라이드 저장
|
||||||
|
- [ ] 편집 상태 표시 (버튼 색상 변경)
|
||||||
|
- [ ] "자동으로 되돌리기" 버튼
|
||||||
|
|
||||||
|
## Phase 3: 컴포넌트 표시/숨김 (계획)
|
||||||
|
|
||||||
|
- [ ] visibility 속성 추가 (모드별 true/false)
|
||||||
|
- [ ] 속성 패널 체크박스 UI
|
||||||
|
- [ ] 렌더러에서 visibility 처리
|
||||||
|
|
||||||
|
## Phase 4: 순서 오버라이드 (계획)
|
||||||
|
|
||||||
|
- [ ] 모드별 children 순서 오버라이드
|
||||||
|
- [ ] 드래그로 순서 변경 UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료된 기능 목록
|
||||||
|
|
||||||
|
### v4 타입 정의
|
||||||
|
|
||||||
- [x] `PopLayoutDataV4` - 단일 소스 레이아웃
|
- [x] `PopLayoutDataV4` - 단일 소스 레이아웃
|
||||||
- [x] `PopContainerV4` - 스택 컨테이너
|
- [x] `PopContainerV4` - 스택 컨테이너
|
||||||
|
|
@ -32,59 +105,70 @@
|
||||||
- [x] `PopResponsiveRuleV4` - 반응형 규칙
|
- [x] `PopResponsiveRuleV4` - 반응형 규칙
|
||||||
- [x] `createEmptyPopLayoutV4()` - 생성 함수
|
- [x] `createEmptyPopLayoutV4()` - 생성 함수
|
||||||
- [x] `isV4Layout()` - 타입 가드
|
- [x] `isV4Layout()` - 타입 가드
|
||||||
|
- [x] `addComponentToV4Layout()` - 컴포넌트 추가
|
||||||
|
- [x] `removeComponentFromV4Layout()` - 컴포넌트 삭제
|
||||||
|
- [x] `updateComponentInV4Layout()` - 컴포넌트 수정
|
||||||
|
- [x] `updateContainerV4()` - 컨테이너 수정
|
||||||
|
- [x] `findContainerV4()` - 컨테이너 찾기
|
||||||
|
|
||||||
### v4 렌더러 (진행)
|
### v4 렌더러
|
||||||
|
|
||||||
- [ ] `PopFlexRenderer` - Flexbox 기반 렌더링
|
- [x] `PopFlexRenderer` - Flexbox 기반 렌더링
|
||||||
- [ ] 컨테이너 재귀 렌더링
|
- [x] 컨테이너 재귀 렌더링 (`ContainerRenderer`)
|
||||||
- [ ] 반응형 규칙 적용 (breakpoint)
|
- [x] 반응형 규칙 적용 (`applyResponsiveRules`)
|
||||||
- [ ] 컴포넌트 숨김 처리 (hideBelow)
|
- [x] 컴포넌트 숨김 처리 (`hideBelow`)
|
||||||
|
- [x] 크기 제약 → CSS 변환 (`calculateSizeStyle`)
|
||||||
|
- [x] 드래그 리사이즈 핸들 (`ComponentRendererV4`)
|
||||||
|
- [x] 드래그 앤 드롭 순서 변경 (`DraggableComponentWrapper`)
|
||||||
|
|
||||||
### 기본 컴포넌트 (대기)
|
### v4 캔버스
|
||||||
|
|
||||||
- [ ] `PopButton` - 터치 버튼
|
- [x] `PopCanvasV4` - v4 전용 캔버스
|
||||||
- [ ] `PopInput` - 텍스트 입력
|
- [x] 뷰포트 프리셋 (4개 모드)
|
||||||
- [ ] `PopLabel` - 텍스트 표시
|
- [x] 너비 슬라이더 (320~1200px)
|
||||||
|
- [x] 줌 컨트롤 (30%~150%)
|
||||||
|
- [x] 패닝 (Space + 드래그)
|
||||||
|
- [x] 드래그 앤 드롭
|
||||||
|
|
||||||
|
### v4 속성 패널
|
||||||
|
|
||||||
|
- [x] `ComponentEditorPanelV4` - 속성 편집 패널
|
||||||
|
- [x] 크기 제약 편집 (fixed/fill/hug)
|
||||||
|
- [x] 컨테이너 설정 (방향, 정렬, 간격)
|
||||||
|
|
||||||
|
### 디자이너 통합
|
||||||
|
|
||||||
|
- [x] `PopDesigner` v3/v4 자동 판별
|
||||||
|
- [x] 새 화면 v4 기본 적용
|
||||||
|
- [x] 기존 v3 화면 하위 호환
|
||||||
|
- [x] `ComponentPaletteV4` v4 전용 팔레트 (Spacer 포함)
|
||||||
|
- [x] Undo/Redo 버튼 및 단축키
|
||||||
|
- [x] 컴포넌트 순서 변경 핸들러 (`handleReorderComponentV4`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v3 vs v4 비교
|
## v3 vs v4 비교
|
||||||
|
|
||||||
| | v3 (기존) | v4 (새로운) |
|
| 항목 | v3 (기존) | v4 (새로운) |
|
||||||
|---|---|---|
|
|------|-----------|-------------|
|
||||||
| 설계 | 4모드 각각 | 1번만 |
|
| 설계 | 4모드 각각 | 1번만 |
|
||||||
| 데이터 | col, row 위치 | 규칙 (fill/fixed/hug) |
|
| 데이터 | col, row 위치 | 규칙 (fill/fixed/hug) |
|
||||||
| 렌더링 | CSS Grid | Flexbox |
|
| 렌더링 | CSS Grid | Flexbox |
|
||||||
| 반응형 | 수동 | 자동 + 규칙 |
|
| 반응형 | 수동 | 자동 + 규칙 |
|
||||||
|
| 새 화면 | - | 기본 적용 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 컴포넌트 로드맵
|
## 관련 파일
|
||||||
|
|
||||||
### Tier 1: Primitive (기본)
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
| 컴포넌트 | 용도 | 상태 |
|
| `PopDesigner.tsx` | v3/v4 통합 디자이너 |
|
||||||
|----------|------|------|
|
| `PopCanvasV4.tsx` | v4 캔버스 (4개 프리셋 + 슬라이더) |
|
||||||
| PopButton | 터치 버튼 | 대기 |
|
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 |
|
||||||
| PopInput | 텍스트 입력 | 대기 |
|
| `ComponentPaletteV4.tsx` | v4 컴포넌트 팔레트 |
|
||||||
| PopLabel | 텍스트 표시 | 대기 |
|
| `ComponentEditorPanelV4.tsx` | v4 속성 편집 패널 |
|
||||||
| PopBadge | 상태 배지 | 계획 |
|
| `pop-layout.ts` | v3/v4 타입 정의 |
|
||||||
|
|
||||||
### Tier 2: Compound (조합)
|
|
||||||
|
|
||||||
| 컴포넌트 | 용도 | 상태 |
|
|
||||||
|----------|------|------|
|
|
||||||
| PopFormField | 라벨 + 입력 | 계획 |
|
|
||||||
| PopCard | 카드 컨테이너 | 계획 |
|
|
||||||
| PopListItem | 목록 항목 | 계획 |
|
|
||||||
|
|
||||||
### Tier 3: Complex (복합)
|
|
||||||
|
|
||||||
| 컴포넌트 | 용도 | 상태 |
|
|
||||||
|----------|------|------|
|
|
||||||
| PopScanner | 바코드/QR 스캔 | 계획 |
|
|
||||||
| PopNumpad | 숫자 키패드 | 계획 |
|
|
||||||
| PopDataList | 페이징 목록 | 계획 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
# POP 저장/조회 규칙
|
||||||
|
|
||||||
|
**AI가 POP 관련 저장/조회 요청을 처리할 때 참고하는 규칙**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 저장 요청 처리
|
||||||
|
|
||||||
|
사용자가 저장/기록/정리/업데이트 등을 요청하면:
|
||||||
|
|
||||||
|
### 1.1 파일 관련
|
||||||
|
|
||||||
|
| 요청 유형 | 처리 방법 |
|
||||||
|
|----------|----------|
|
||||||
|
| 새 파일 추가됨 | `FILES.md`에 파일 정보 추가 |
|
||||||
|
| 구조 변경됨 | `ARCHITECTURE.md` 업데이트 |
|
||||||
|
| 작업 완료 | `CHANGELOG.md`에 기록 |
|
||||||
|
| 중요 결정 | `decisions/` 폴더에 ADR 추가 |
|
||||||
|
|
||||||
|
### 1.2 rangraph 동기화
|
||||||
|
|
||||||
|
| 요청 유형 | rangraph 처리 |
|
||||||
|
|----------|--------------|
|
||||||
|
| 중요 결정 | `save_decision` 호출 |
|
||||||
|
| 교훈/규칙 | `save_lesson` 호출 |
|
||||||
|
| 새 키워드 | `add_keyword` 호출 |
|
||||||
|
| 작업 흐름 | `workflow_submit` 호출 |
|
||||||
|
|
||||||
|
### 1.3 예시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "오늘 작업 정리해줘"
|
||||||
|
AI:
|
||||||
|
1. CHANGELOG.md에 오늘 날짜로 Added/Changed/Fixed 기록
|
||||||
|
2. rangraph save_decision 또는 save_lesson 호출
|
||||||
|
3. 필요시 FILES.md, ARCHITECTURE.md 업데이트
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 조회 요청 처리
|
||||||
|
|
||||||
|
사용자가 조회/검색/찾기 등을 요청하면:
|
||||||
|
|
||||||
|
### 2.1 popdocs 우선순위
|
||||||
|
|
||||||
|
| 필요한 정보 | 참조 문서 | 토큰 비용 |
|
||||||
|
|------------|----------|----------|
|
||||||
|
| 빠른 참조 | `README.md` | 낮음 (151줄) |
|
||||||
|
| 전체 구조 | `ARCHITECTURE.md` | 중간 (530줄) |
|
||||||
|
| 파일 위치 | `FILES.md` | 높음 (900줄) |
|
||||||
|
| 기술 스펙 | `SPEC.md` | 중간 |
|
||||||
|
| 컴포넌트 | `components-spec.md` | 중간 |
|
||||||
|
| 진행 상황 | `PLAN.md` | 낮음 |
|
||||||
|
| 변경 이력 | `CHANGELOG.md` | 낮음 |
|
||||||
|
|
||||||
|
### 2.2 조회 전략
|
||||||
|
|
||||||
|
```
|
||||||
|
1단계: rangraph search_memory로 빠르게 확인
|
||||||
|
2단계: 관련 popdocs 문서 참조
|
||||||
|
3단계: 필요시 실제 소스 파일 Read
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 예시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "v4 렌더러 어디있어?"
|
||||||
|
AI:
|
||||||
|
1. rangraph search_memory "v4 렌더러" (캐시된 정보)
|
||||||
|
2. FILES.md에서 확인: PopFlexRenderer.tsx (498줄)
|
||||||
|
3. 필요시 실제 파일 Read
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 토큰 효율화 전략
|
||||||
|
|
||||||
|
### 3.1 문서 읽기 우선순위
|
||||||
|
|
||||||
|
```
|
||||||
|
최소 토큰: README.md (빠른 참조)
|
||||||
|
↓
|
||||||
|
중간 토큰: ARCHITECTURE.md (구조 이해)
|
||||||
|
↓
|
||||||
|
필요시: FILES.md (파일 상세)
|
||||||
|
↓
|
||||||
|
마지막: 실제 소스 파일
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 효율적 패턴
|
||||||
|
|
||||||
|
| 상황 | 효율적 방법 | 비효율적 방법 |
|
||||||
|
|------|------------|--------------|
|
||||||
|
| 파일 위치 찾기 | FILES.md 검색 | 전체 폴더 탐색 |
|
||||||
|
| 구조 이해 | ARCHITECTURE.md | 모든 파일 읽기 |
|
||||||
|
| 빠른 확인 | rangraph 검색 | 문서 전체 읽기 |
|
||||||
|
| 특정 코드 | FILES.md → Read | Glob + 전체 Read |
|
||||||
|
|
||||||
|
### 3.3 캐싱 활용
|
||||||
|
|
||||||
|
rangraph에 저장된 정보:
|
||||||
|
- POP 문서 구조 (save_decision)
|
||||||
|
- 저장/조회 규칙 (save_lesson)
|
||||||
|
- 핵심 파일 위치 (save_lesson)
|
||||||
|
- 키워드: popdocs, ARCHITECTURE.md, FILES.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 문서별 용도
|
||||||
|
|
||||||
|
| 문서 | 읽을 때 | 수정할 때 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| README.md | 빠른 참조 필요 시 | 문서 구조 변경 시 |
|
||||||
|
| ARCHITECTURE.md | 구조 이해 필요 시 | 폴더/모듈 변경 시 |
|
||||||
|
| FILES.md | 파일 찾을 때 | 파일 추가/삭제 시 |
|
||||||
|
| SPEC.md | 기술 스펙 확인 시 | 스펙 변경 시 |
|
||||||
|
| PLAN.md | 진행 상황 확인 시 | 계획 변경 시 |
|
||||||
|
| CHANGELOG.md | 이력 확인 시 | 작업 완료 시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 실제 처리 예시
|
||||||
|
|
||||||
|
### 5.1 저장 요청
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "PopButton 컴포넌트 만들었어"
|
||||||
|
|
||||||
|
AI 처리:
|
||||||
|
1. FILES.md에 파일 정보 추가
|
||||||
|
2. CHANGELOG.md에 Added 기록
|
||||||
|
3. components-spec.md에 스펙 추가 (필요시)
|
||||||
|
4. rangraph save_decision 호출
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 조회 요청
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "v4 캔버스 어떻게 동작해?"
|
||||||
|
|
||||||
|
AI 처리:
|
||||||
|
1. rangraph search_memory "v4 캔버스"
|
||||||
|
2. FILES.md에서 PopCanvasV4.tsx 확인
|
||||||
|
3. ARCHITECTURE.md에서 캔버스 섹션 확인
|
||||||
|
4. 필요시 PopCanvasV4.tsx 직접 Read
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 업데이트 요청
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "PLAN.md 업데이트해줘"
|
||||||
|
|
||||||
|
AI 처리:
|
||||||
|
1. 현재 PLAN.md 읽기
|
||||||
|
2. 완료된 항목 체크, 새 항목 추가
|
||||||
|
3. rangraph에 진행 상황 저장 (필요시)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 키워드 체계
|
||||||
|
|
||||||
|
rangraph 키워드 카테고리:
|
||||||
|
|
||||||
|
| 카테고리 | 키워드 예시 |
|
||||||
|
|----------|-----------|
|
||||||
|
| pop | popdocs, ARCHITECTURE.md, FILES.md |
|
||||||
|
| v4 | PopFlexRenderer, PopCanvasV4, 제약조건 |
|
||||||
|
| designer | PopDesigner, PopPanel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 이 문서의 용도
|
||||||
|
|
||||||
|
- AI가 POP 관련 요청을 받으면 이 규칙을 참고
|
||||||
|
- 저장 시: popdocs 문서 + rangraph 동기화
|
||||||
|
- 조회 시: 토큰 효율적인 순서로 확인
|
||||||
|
- 사용자가 규칙 변경을 요청하면 이 문서 수정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최종 업데이트: 2026-02-04*
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
# POP v4 통합 설계 모드 스펙
|
||||||
|
|
||||||
|
**작성일: 2026-02-04**
|
||||||
|
**상태: Phase 1.5 완료 (Flexbox 가로 배치 + Spacer)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
v3/v4 탭을 제거하고, **v4 자동 모드를 기본**으로 하되 **모드별 오버라이드** 기능을 지원하는 통합 설계 방식.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 개념
|
||||||
|
|
||||||
|
### 기존 방식 (v3)
|
||||||
|
```
|
||||||
|
4개 모드 각각 설계 필요
|
||||||
|
태블릿 가로: 버튼 → col 1, row 1
|
||||||
|
태블릿 세로: 버튼 → col 1, row 5 (따로 설정)
|
||||||
|
모바일 가로: 버튼 → col 1, row 1 (따로 설정)
|
||||||
|
모바일 세로: 버튼 → col 1, row 10 (따로 설정)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 새로운 방식 (v4 통합)
|
||||||
|
```
|
||||||
|
기본: 태블릿 가로에서 규칙 설정
|
||||||
|
버튼 → width: fill, height: 48px
|
||||||
|
|
||||||
|
결과: 모든 모드에 자동 적용
|
||||||
|
태블릿 가로: 버튼 너비 1024px, 높이 48px
|
||||||
|
태블릿 세로: 버튼 너비 768px, 높이 48px
|
||||||
|
모바일 가로: 버튼 너비 667px, 높이 48px
|
||||||
|
모바일 세로: 버튼 너비 375px, 높이 48px
|
||||||
|
|
||||||
|
예외: 특정 모드에서 편집하면 오버라이드
|
||||||
|
모바일 세로: 버튼 높이 36px (수동 설정)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 UI (Phase 1.5 완료)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ← 목록 화면명 *변경됨 [↶][↷] 자동 레이아웃 (v4) [저장] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 편집 중: v4 (자동 반응형) │
|
||||||
|
│ 규칙 기반 레이아웃 │
|
||||||
|
├────────────┬────────────────────────────────────┬───────────────┤
|
||||||
|
│ 컴포넌트 │ 미리보기: [모바일↕][모바일↔] │ 속성 │
|
||||||
|
│ │ [태블릿↕][태블릿↔(기본)] │ │
|
||||||
|
│ 필드 │ 너비: [====●====] 1024 x 768 │ │
|
||||||
|
│ 버튼 │ │ │
|
||||||
|
│ 리스트 │ ┌──────────────────────────────┐ │ │
|
||||||
|
│ 인디케이터 │ │ [필드1] [필드2] [필드3] │ │ │
|
||||||
|
│ 스캐너 │ │ [필드4] [Spacer] [버튼] │ │ │
|
||||||
|
│ 숫자패드 │ │ │ │ │
|
||||||
|
│ 스페이서 │ │ (가로 배치 + 자동 줄바꿈) │ │ │
|
||||||
|
│ │ │ (스크롤 가능) │ │ │
|
||||||
|
│ │ └──────────────────────────────┘ │ │
|
||||||
|
│ │ 태블릿 가로 (1024x768) │ │
|
||||||
|
└────────────┴────────────────────────────────────┴───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 레이아웃 방식 (업계 표준)
|
||||||
|
|
||||||
|
| 서비스 | 방식 |
|
||||||
|
|--------|------|
|
||||||
|
| Figma | Auto Layout (Flexbox) |
|
||||||
|
| Webflow | Flexbox + CSS Grid |
|
||||||
|
| FlutterFlow | Row/Column/Stack |
|
||||||
|
| Adalo 2.0 | Flexbox + Constraints |
|
||||||
|
| **POP v4** | **Flexbox (horizontal + wrap)** |
|
||||||
|
|
||||||
|
### Spacer 컴포넌트 사용법
|
||||||
|
|
||||||
|
```
|
||||||
|
[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로
|
||||||
|
[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로
|
||||||
|
[Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프리셋 버튼 (4개 모드)
|
||||||
|
|
||||||
|
| 버튼 | 해상도 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 모바일↕ | 375 x 667 | 모바일 세로 |
|
||||||
|
| 모바일↔ | 667 x 375 | 모바일 가로 |
|
||||||
|
| 태블릿↕ | 768 x 1024 | 태블릿 세로 |
|
||||||
|
| 태블릿↔* | 1024 x 768 | 태블릿 가로 (기본) |
|
||||||
|
|
||||||
|
### 레이아웃 판별 로직
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 새 화면 또는 빈 레이아웃 → v4로 시작
|
||||||
|
const hasValidLayout = loadedLayout && loadedLayout.version;
|
||||||
|
const hasComponents = loadedLayout?.components &&
|
||||||
|
Object.keys(loadedLayout.components).length > 0;
|
||||||
|
|
||||||
|
if (hasValidLayout && hasComponents) {
|
||||||
|
// v4면 v4, 그 외 v3로 변환
|
||||||
|
} else {
|
||||||
|
// v4로 새로 시작
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 오버라이드 동작 (Phase 2 예정)
|
||||||
|
|
||||||
|
### 자동 감지 방식
|
||||||
|
1. 사용자가 **태블릿 가로(기본)**에서 편집 → 기본 규칙 저장
|
||||||
|
2. 사용자가 **다른 모드**에서 편집 → 해당 모드 오버라이드 자동 저장
|
||||||
|
3. 편집 안 한 모드 → 기본 규칙에서 자동 계산
|
||||||
|
|
||||||
|
### 편집 상태 표시
|
||||||
|
|
||||||
|
| 상태 | 버튼 색상 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 기본 (태블릿 가로) | 강조 + "(기본)" | 항상 표시 |
|
||||||
|
| 자동 | 기본 색상 | 편집 안 함 |
|
||||||
|
| 편집됨 | 강조 색상 | 오버라이드 있음 |
|
||||||
|
|
||||||
|
### 되돌리기
|
||||||
|
- 편집된 모드에만 "자동으로 되돌리기" 버튼 활성화
|
||||||
|
- 클릭 시 오버라이드 삭제 → 기본 규칙 복원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 구조
|
||||||
|
|
||||||
|
### PopLayoutDataV4 (Phase 2에서 수정 예정)
|
||||||
|
```typescript
|
||||||
|
interface PopLayoutDataV4 {
|
||||||
|
version: "pop-4.0";
|
||||||
|
root: PopContainerV4;
|
||||||
|
components: Record<string, PopComponentDefinitionV4>;
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
settings: PopGlobalSettingsV4;
|
||||||
|
|
||||||
|
// 모드별 오버라이드 (Phase 2에서 추가)
|
||||||
|
overrides?: {
|
||||||
|
mobile_portrait?: ModeOverride;
|
||||||
|
mobile_landscape?: ModeOverride;
|
||||||
|
tablet_portrait?: ModeOverride;
|
||||||
|
// tablet_landscape는 기본이므로 오버라이드 없음
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModeOverride {
|
||||||
|
components?: Record<string, Partial<PopComponentDefinitionV4>>;
|
||||||
|
containers?: Record<string, Partial<PopContainerV4>>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PopComponentDefinitionV4 (Phase 3에서 수정 예정)
|
||||||
|
```typescript
|
||||||
|
interface PopComponentDefinitionV4 {
|
||||||
|
type: PopComponentType;
|
||||||
|
label?: string;
|
||||||
|
size: PopSizeConstraintV4;
|
||||||
|
alignSelf?: "start" | "center" | "end" | "stretch";
|
||||||
|
|
||||||
|
// 모드별 표시 설정 (Phase 3에서 추가)
|
||||||
|
visibility?: {
|
||||||
|
mobile_portrait?: boolean; // 기본 true
|
||||||
|
mobile_landscape?: boolean; // 기본 true
|
||||||
|
tablet_portrait?: boolean; // 기본 true
|
||||||
|
tablet_landscape?: boolean; // 기본 true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트 표시/숨김 (Phase 3 예정)
|
||||||
|
|
||||||
|
### 업계 표준 (Webflow, Figma)
|
||||||
|
- 삭제가 아닌 **숨김** 처리
|
||||||
|
- 특정 모드에서만 `display: none`
|
||||||
|
- 언제든 다시 표시 가능
|
||||||
|
|
||||||
|
### UI (속성 패널)
|
||||||
|
```
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ 버튼 │
|
||||||
|
├─────────────────────────┤
|
||||||
|
│ 표시 설정 │
|
||||||
|
│ [x] 모바일 세로 │
|
||||||
|
│ [x] 모바일 가로 │
|
||||||
|
│ [x] 태블릿 세로 │
|
||||||
|
│ [x] 태블릿 가로 │
|
||||||
|
│ │
|
||||||
|
│ (체크 해제 = 숨김) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 상태
|
||||||
|
|
||||||
|
### Phase 1: 기본 구조 (완료)
|
||||||
|
- [x] v3/v4 탭 제거 (자동 판별)
|
||||||
|
- [x] 새 화면 → v4로 시작
|
||||||
|
- [x] 기존 v3 화면 → v3로 로드 (하위 호환)
|
||||||
|
- [x] 4개 프리셋 버튼 (모바일↕, 모바일↔, 태블릿↕, 태블릿↔)
|
||||||
|
- [x] 기본 프리셋 표시 (태블릿 가로 + "(기본)")
|
||||||
|
- [x] 슬라이더 유지 (320~1200px, 비율 유지)
|
||||||
|
- [x] ComponentPaletteV4 생성
|
||||||
|
|
||||||
|
### Phase 1.5: Flexbox 가로 배치 (완료)
|
||||||
|
- [x] Undo/Redo (Ctrl+Z / Ctrl+Shift+Z, 데스크탑 모드와 동일 방식)
|
||||||
|
- [x] 드래그 리사이즈 핸들
|
||||||
|
- [x] Flexbox 가로 배치 (`direction: horizontal`, `wrap: true`)
|
||||||
|
- [x] 컴포넌트 타입별 기본 크기 설정
|
||||||
|
- [x] Spacer 컴포넌트 (`pop-spacer`)
|
||||||
|
- [x] 컴포넌트 순서 변경 (드래그 앤 드롭)
|
||||||
|
- [x] 디바이스 스크린 무한 스크롤
|
||||||
|
|
||||||
|
### Phase 2: 오버라이드 기능 (다음)
|
||||||
|
- [ ] ModeOverride 데이터 구조 추가
|
||||||
|
- [ ] 편집 감지 → 자동 오버라이드 저장
|
||||||
|
- [ ] 편집 상태 표시 (버튼 색상)
|
||||||
|
- [ ] "자동으로 되돌리기" 버튼
|
||||||
|
|
||||||
|
### Phase 3: 컴포넌트 표시/숨김
|
||||||
|
- [ ] visibility 속성 추가
|
||||||
|
- [ ] 속성 패널 체크박스 UI
|
||||||
|
- [ ] 렌더러에서 visibility 처리
|
||||||
|
|
||||||
|
### Phase 4: 순서 오버라이드
|
||||||
|
- [ ] 모드별 children 순서 오버라이드
|
||||||
|
- [ ] 드래그로 순서 변경 UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `PopDesigner.tsx` | v3/v4 통합 디자이너 | 완료 |
|
||||||
|
| `PopCanvasV4.tsx` | v4 캔버스 (4개 프리셋 + 슬라이더) | 완료 |
|
||||||
|
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 | 완료 |
|
||||||
|
| `ComponentPaletteV4.tsx` | v4 컴포넌트 팔레트 | 완료 |
|
||||||
|
| `ComponentEditorPanelV4.tsx` | v4 속성 편집 패널 | 완료 |
|
||||||
|
| `pop-layout.ts` | v3/v4 타입 정의 | 완료, Phase 2-3에서 수정 예정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 문서는 v4 통합 설계 모드의 스펙을 정의합니다.*
|
||||||
|
*최종 업데이트: 2026-02-04*
|
||||||
|
|
@ -6,11 +6,11 @@
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
### 총 컴포넌트 수: 13개
|
### 총 컴포넌트 수: 14개
|
||||||
|
|
||||||
| 분류 | 개수 | 컴포넌트 |
|
| 분류 | 개수 | 컴포넌트 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| 레이아웃 | 2 | container, tab-panel |
|
| 레이아웃 | 3 | container, tab-panel, **spacer** |
|
||||||
| 데이터 표시 | 4 | data-table, card-list, kpi-gauge, status-indicator |
|
| 데이터 표시 | 4 | data-table, card-list, kpi-gauge, status-indicator |
|
||||||
| 입력 | 4 | number-pad, barcode-scanner, form-field, action-button |
|
| 입력 | 4 | number-pad, barcode-scanner, form-field, action-button |
|
||||||
| 특화 기능 | 3 | timer, alarm-list, process-flow |
|
| 특화 기능 | 3 | timer, alarm-list, process-flow |
|
||||||
|
|
@ -37,17 +37,18 @@
|
||||||
|---|----------|------|
|
|---|----------|------|
|
||||||
| 1 | pop-container | 레이아웃 뼈대 |
|
| 1 | pop-container | 레이아웃 뼈대 |
|
||||||
| 2 | pop-tab-panel | 정보 분류 |
|
| 2 | pop-tab-panel | 정보 분류 |
|
||||||
| 3 | pop-data-table | 대량 데이터 |
|
| 3 | **pop-spacer** | **빈 공간 (정렬용)** |
|
||||||
| 4 | pop-card-list | 시각적 목록 |
|
| 4 | pop-data-table | 대량 데이터 |
|
||||||
| 5 | pop-kpi-gauge | 목표 달성률 |
|
| 5 | pop-card-list | 시각적 목록 |
|
||||||
| 6 | pop-status-indicator | 상태 표시 |
|
| 6 | pop-kpi-gauge | 목표 달성률 |
|
||||||
| 7 | pop-number-pad | 수량 입력 |
|
| 7 | pop-status-indicator | 상태 표시 |
|
||||||
| 8 | pop-barcode-scanner | 스캔 입력 |
|
| 8 | pop-number-pad | 수량 입력 |
|
||||||
| 9 | pop-form-field | 범용 입력 |
|
| 9 | pop-barcode-scanner | 스캔 입력 |
|
||||||
| 10 | pop-action-button | 작업 실행 |
|
| 10 | pop-form-field | 범용 입력 |
|
||||||
| 11 | pop-timer | 시간 측정 |
|
| 11 | pop-action-button | 작업 실행 |
|
||||||
| 12 | pop-alarm-list | 알람 관리 |
|
| 12 | pop-timer | 시간 측정 |
|
||||||
| 13 | pop-process-flow | 공정 현황 |
|
| 13 | pop-alarm-list | 알람 관리 |
|
||||||
|
| 14 | pop-process-flow | 공정 현황 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -79,6 +80,35 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 3. pop-spacer (v4 전용)
|
||||||
|
|
||||||
|
역할: 빈 공간을 차지하여 레이아웃 정렬에 사용 (Figma, Webflow 등 업계 표준)
|
||||||
|
|
||||||
|
| 기능 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 공간 채우기 | 남은 공간을 자동으로 채움 (`width: fill`) |
|
||||||
|
| 고정 크기 | 특정 크기의 빈 공간 (`width: fixed`) |
|
||||||
|
| 정렬 용도 | 컴포넌트를 오른쪽/가운데 정렬 |
|
||||||
|
|
||||||
|
### 사용 예시
|
||||||
|
|
||||||
|
```
|
||||||
|
[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로
|
||||||
|
[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로
|
||||||
|
[Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로
|
||||||
|
```
|
||||||
|
|
||||||
|
### 기본 설정
|
||||||
|
|
||||||
|
| 속성 | 기본값 |
|
||||||
|
|------|--------|
|
||||||
|
| width | fill (남은 공간 채움) |
|
||||||
|
| height | 48px (고정) |
|
||||||
|
| 디자인 모드 표시 | 점선 배경 + "빈 공간" 텍스트 |
|
||||||
|
| 실제 모드 | 완전히 투명 (공간만 차지) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 3. pop-data-table
|
## 3. pop-data-table
|
||||||
|
|
||||||
역할: 대량 데이터 표시, 선택, 편집
|
역할: 대량 데이터 표시, 선택, 편집
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue