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 {
|
||||
PopLayoutDataV3,
|
||||
PopLayoutDataV4,
|
||||
PopLayoutModeKey,
|
||||
ensureV3Layout,
|
||||
isV3Layout,
|
||||
isV4Layout,
|
||||
} from "@/components/pop/designer/types/pop-layout";
|
||||
import {
|
||||
PopLayoutRenderer,
|
||||
hasBaseLayout,
|
||||
getEffectiveModeLayout,
|
||||
} from "@/components/pop/designer/renderers";
|
||||
import { PopFlexRenderer } from "@/components/pop/designer/renderers/PopFlexRenderer";
|
||||
import {
|
||||
useResponsiveMode,
|
||||
useResponsiveModeWithOverride,
|
||||
|
|
@ -63,12 +66,18 @@ const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => {
|
|||
return layout && layout.version === "pop-3.0" && layout.layouts && layout.components;
|
||||
};
|
||||
|
||||
// v1/v2 레이아웃인지 확인 (마이그레이션 대상)
|
||||
// v4.0 레이아웃인지 확인
|
||||
const isPopLayoutV4 = (layout: any): layout is PopLayoutDataV4 => {
|
||||
return layout && layout.version === "pop-4.0" && layout.root && layout.components;
|
||||
};
|
||||
|
||||
// v1/v2/v3/v4 레이아웃인지 확인
|
||||
const isPopLayout = (layout: any): boolean => {
|
||||
return layout && (
|
||||
layout.version === "pop-1.0" ||
|
||||
layout.version === "pop-2.0" ||
|
||||
layout.version === "pop-3.0"
|
||||
layout.version === "pop-3.0" ||
|
||||
layout.version === "pop-4.0"
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -101,6 +110,7 @@ function PopScreenViewPage() {
|
|||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
|
||||
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -108,6 +118,19 @@ function PopScreenViewPage() {
|
|||
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
||||
const [tableRefreshKey, setTableRefreshKey] = useState(0);
|
||||
|
||||
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
|
||||
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportWidth = () => {
|
||||
setViewportWidth(Math.min(window.innerWidth, 1366));
|
||||
};
|
||||
|
||||
updateViewportWidth();
|
||||
window.addEventListener("resize", updateViewportWidth);
|
||||
return () => window.removeEventListener("resize", updateViewportWidth);
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 초기화
|
||||
useEffect(() => {
|
||||
const initComponents = async () => {
|
||||
|
|
@ -133,10 +156,17 @@ function PopScreenViewPage() {
|
|||
try {
|
||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||
|
||||
if (popLayout && isPopLayout(popLayout)) {
|
||||
if (popLayout && isPopLayoutV4(popLayout)) {
|
||||
// v4 레이아웃
|
||||
setPopLayoutV4(popLayout);
|
||||
setPopLayoutV3(null);
|
||||
const componentCount = Object.keys(popLayout.components).length;
|
||||
console.log(`[POP] v4 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
||||
} else if (popLayout && isPopLayout(popLayout)) {
|
||||
// v1/v2/v3 → v3로 변환
|
||||
const v3Layout = ensureV3Layout(popLayout);
|
||||
setPopLayoutV3(v3Layout);
|
||||
setPopLayoutV4(null);
|
||||
|
||||
const componentCount = Object.keys(v3Layout.components).length;
|
||||
console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
||||
|
|
@ -151,11 +181,13 @@ function PopScreenViewPage() {
|
|||
} else {
|
||||
console.log("[POP] 레이아웃 없음");
|
||||
setPopLayoutV3(null);
|
||||
setPopLayoutV4(null);
|
||||
setLayout(null);
|
||||
}
|
||||
} catch (layoutError) {
|
||||
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
||||
setPopLayoutV3(null);
|
||||
setPopLayoutV4(null);
|
||||
setLayout(null);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -304,8 +336,20 @@ function PopScreenViewPage() {
|
|||
flexShrink: 0,
|
||||
} : undefined}
|
||||
>
|
||||
{/* POP 레이아웃 v3.0 렌더링 */}
|
||||
{popLayoutV3 ? (
|
||||
{/* POP 레이아웃 v4.0 렌더링 */}
|
||||
{popLayoutV4 ? (
|
||||
<div
|
||||
className="mx-auto h-full"
|
||||
style={{ maxWidth: 1366 }}
|
||||
>
|
||||
<PopFlexRenderer
|
||||
layout={popLayoutV4}
|
||||
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
</div>
|
||||
) : popLayoutV3 ? (
|
||||
/* POP 레이아웃 v3.0 렌더링 */
|
||||
<PopLayoutV3Renderer
|
||||
layout={popLayoutV3}
|
||||
modeKey={currentModeKey}
|
||||
|
|
|
|||
|
|
@ -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 { DndProvider } from "react-dnd";
|
||||
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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
|
|
@ -14,26 +14,41 @@ import {
|
|||
import { toast } from "sonner";
|
||||
|
||||
import { PopCanvas } from "./PopCanvas";
|
||||
import { PopCanvasV4 } from "./PopCanvasV4";
|
||||
import { PopPanel } from "./panels/PopPanel";
|
||||
import { ComponentPaletteV4 } from "./panels/ComponentPaletteV4";
|
||||
import { ComponentEditorPanelV4 } from "./panels/ComponentEditorPanelV4";
|
||||
import {
|
||||
PopLayoutDataV3,
|
||||
PopLayoutDataV4,
|
||||
PopLayoutModeKey,
|
||||
PopComponentType,
|
||||
GridPosition,
|
||||
PopComponentDefinition,
|
||||
PopComponentDefinitionV4,
|
||||
PopContainerV4,
|
||||
PopSizeConstraintV4,
|
||||
createEmptyPopLayoutV3,
|
||||
createEmptyPopLayoutV4,
|
||||
ensureV3Layout,
|
||||
addComponentToV3Layout,
|
||||
removeComponentFromV3Layout,
|
||||
updateComponentPositionInModeV3,
|
||||
addComponentToV4Layout,
|
||||
removeComponentFromV4Layout,
|
||||
updateComponentInV4Layout,
|
||||
updateContainerV4,
|
||||
findContainerV4,
|
||||
isV3Layout,
|
||||
isV4Layout,
|
||||
} from "./types/pop-layout";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
|
||||
// ========================================
|
||||
// 디바이스 타입
|
||||
// 레이아웃 모드 타입
|
||||
// ========================================
|
||||
type LayoutMode = "v3" | "v4";
|
||||
type DeviceType = "mobile" | "tablet";
|
||||
|
||||
// ========================================
|
||||
|
|
@ -46,7 +61,9 @@ interface PopDesignerProps {
|
|||
}
|
||||
|
||||
// ========================================
|
||||
// 메인 컴포넌트 (v3: 섹션 없이 컴포넌트 직접 배치)
|
||||
// 메인 컴포넌트 (v3/v4 통합)
|
||||
// - 새 화면: v4로 시작
|
||||
// - 기존 v3 화면: v3로 로드 (하위 호환)
|
||||
// ========================================
|
||||
export default function PopDesigner({
|
||||
selectedScreen,
|
||||
|
|
@ -54,27 +71,129 @@ export default function PopDesigner({
|
|||
onScreenUpdate,
|
||||
}: 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 [isSaving, setIsSaving] = 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 [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
||||
|
||||
// ========================================
|
||||
// 선택 상태 (v3: 섹션 없음, 컴포넌트만)
|
||||
// 선택 상태
|
||||
// ========================================
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
|
||||
|
||||
// 선택된 컴포넌트 정의
|
||||
const selectedComponent: PopComponentDefinition | null = selectedComponentId
|
||||
? layout.components[selectedComponentId] || null
|
||||
// 선택된 컴포넌트/컨테이너
|
||||
const selectedComponentV3: PopComponentDefinition | null = selectedComponentId
|
||||
? 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;
|
||||
|
||||
// ========================================
|
||||
|
|
@ -87,26 +206,46 @@ export default function PopDesigner({
|
|||
setIsLoading(true);
|
||||
try {
|
||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||
|
||||
// 유효한 레이아웃인지 확인:
|
||||
// 1. version 필드 필수
|
||||
// 2. 컴포넌트가 있어야 함 (빈 레이아웃은 새 화면 취급)
|
||||
const hasValidLayout = loadedLayout && loadedLayout.version;
|
||||
const hasComponents = loadedLayout?.components && Object.keys(loadedLayout.components).length > 0;
|
||||
|
||||
if (loadedLayout) {
|
||||
// v1, v2, v3 → v3로 변환
|
||||
const v3Layout = ensureV3Layout(loadedLayout);
|
||||
setLayout(v3Layout);
|
||||
|
||||
const componentCount = Object.keys(v3Layout.components).length;
|
||||
console.log(`POP v3 레이아웃 로드 성공: ${componentCount}개 컴포넌트`);
|
||||
|
||||
if (!isV3Layout(loadedLayout)) {
|
||||
console.log("v1/v2 → v3 자동 마이그레이션 완료");
|
||||
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);
|
||||
setLayoutV3(v3Layout);
|
||||
setHistoryV3([v3Layout]);
|
||||
setHistoryIndexV3(0);
|
||||
setLayoutMode("v3");
|
||||
console.log(`POP v3 레이아웃 로드: ${Object.keys(v3Layout.components).length}개 컴포넌트`);
|
||||
}
|
||||
} else {
|
||||
console.log("POP 레이아웃 없음, 빈 v3 레이아웃 생성");
|
||||
setLayout(createEmptyPopLayoutV3());
|
||||
// 새 화면 또는 빈 레이아웃 → v4로 시작
|
||||
const emptyLayout = createEmptyPopLayoutV4();
|
||||
setLayoutV4(emptyLayout);
|
||||
setHistoryV4([emptyLayout]);
|
||||
setHistoryIndexV4(0);
|
||||
setLayoutMode("v4");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이아웃 로드 실패:", error);
|
||||
toast.error("레이아웃을 불러오는데 실패했습니다");
|
||||
setLayout(createEmptyPopLayoutV3());
|
||||
const emptyLayout = createEmptyPopLayoutV4();
|
||||
setLayoutV4(emptyLayout);
|
||||
setHistoryV4([emptyLayout]);
|
||||
setHistoryIndexV4(0);
|
||||
setLayoutMode("v4");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -123,7 +262,8 @@ export default function PopDesigner({
|
|||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, layout);
|
||||
const layoutToSave = layoutMode === "v3" ? layoutV3 : layoutV4;
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, layoutToSave);
|
||||
toast.success("저장되었습니다");
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
|
|
@ -132,73 +272,180 @@ export default function PopDesigner({
|
|||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [selectedScreen?.screenId, layout]);
|
||||
}, [selectedScreen?.screenId, layoutMode, layoutV3, layoutV4]);
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 추가 (4모드 동기화)
|
||||
// v3: 컴포넌트 핸들러
|
||||
// ========================================
|
||||
const handleDropComponent = useCallback(
|
||||
const handleDropComponentV3 = useCallback(
|
||||
(type: PopComponentType, gridPosition: GridPosition) => {
|
||||
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);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[]
|
||||
[layoutV3, saveToHistoryV3]
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 정의 업데이트
|
||||
// ========================================
|
||||
const handleUpdateComponentDefinition = useCallback(
|
||||
const handleUpdateComponentDefinitionV3 = useCallback(
|
||||
(componentId: string, updates: Partial<PopComponentDefinition>) => {
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
const newLayout = {
|
||||
...layoutV3,
|
||||
components: {
|
||||
...prev.components,
|
||||
[componentId]: {
|
||||
...prev.components[componentId],
|
||||
...updates,
|
||||
},
|
||||
...layoutV3.components,
|
||||
[componentId]: { ...layoutV3.components[componentId], ...updates },
|
||||
},
|
||||
}));
|
||||
};
|
||||
setLayoutV3(newLayout);
|
||||
saveToHistoryV3(newLayout);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[]
|
||||
[layoutV3, saveToHistoryV3]
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 위치 업데이트 (현재 모드만)
|
||||
// ========================================
|
||||
const handleUpdateComponentPosition = useCallback(
|
||||
const handleUpdateComponentPositionV3 = useCallback(
|
||||
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
||||
const targetMode = modeKey || activeModeKey;
|
||||
setLayout((prev) => updateComponentPositionInModeV3(prev, targetMode, componentId, position));
|
||||
const newLayout = updateComponentPositionInModeV3(layoutV3, targetMode, componentId, position);
|
||||
setLayoutV3(newLayout);
|
||||
saveToHistoryV3(newLayout);
|
||||
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모드 동기화)
|
||||
// ========================================
|
||||
const handleDeleteComponent = useCallback((componentId: string) => {
|
||||
setLayout((prev) => removeComponentFromV3Layout(prev, componentId));
|
||||
setSelectedComponentId(null);
|
||||
setHasChanges(true);
|
||||
}, []);
|
||||
|
||||
// ========================================
|
||||
// 디바이스 전환
|
||||
// v3: 디바이스/모드 전환
|
||||
// ========================================
|
||||
const handleDeviceChange = useCallback((device: DeviceType) => {
|
||||
setActiveDevice(device);
|
||||
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
|
||||
}, []);
|
||||
|
||||
// ========================================
|
||||
// 모드 키 전환
|
||||
// ========================================
|
||||
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
|
||||
setActiveModeKey(modeKey);
|
||||
}, []);
|
||||
|
|
@ -217,33 +464,72 @@ export default function PopDesigner({
|
|||
}, [hasChanges, onBackToList]);
|
||||
|
||||
// ========================================
|
||||
// Delete 키 삭제 기능
|
||||
// 단축키 처리 (Delete, Undo, Redo)
|
||||
// ========================================
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Delete / Backspace: 컴포넌트 삭제
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
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);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedComponentId, handleDeleteComponent]);
|
||||
}, [selectedComponentId, layoutMode, handleDeleteComponentV3, handleDeleteComponentV4, canUndo, canRedo, handleUndo, handleRedo]);
|
||||
|
||||
// ========================================
|
||||
// 로딩 상태
|
||||
// 로딩
|
||||
// ========================================
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -270,37 +556,67 @@ export default function PopDesigner({
|
|||
<span className="text-sm font-medium">
|
||||
{selectedScreen?.screenName || "POP 화면"}
|
||||
</span>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-orange-500">*변경됨</span>
|
||||
{hasChanges && <span className="text-xs text-orange-500">*변경됨</span>}
|
||||
</div>
|
||||
|
||||
{/* 중앙: 레이아웃 버전 + v3 디바이스 전환 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{layoutMode === "v4" ? "자동 레이아웃 (v4)" : "4모드 레이아웃 (v3)"}
|
||||
</span>
|
||||
|
||||
{layoutMode === "v3" && (
|
||||
<Tabs value={activeDevice} onValueChange={(v) => handleDeviceChange(v as DeviceType)}>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
|
||||
<Tablet className="mr-1 h-3.5 w-3.5" />
|
||||
태블릿
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mobile" className="h-7 px-3 text-xs">
|
||||
<Smartphone className="mr-1 h-3.5 w-3.5" />
|
||||
모바일
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 중앙: 디바이스 전환 */}
|
||||
{/* 오른쪽: Undo/Redo + 저장 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs
|
||||
value={activeDevice}
|
||||
onValueChange={(v) => handleDeviceChange(v as DeviceType)}
|
||||
>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
|
||||
<Tablet className="mr-1 h-3.5 w-3.5" />
|
||||
태블릿
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mobile" className="h-7 px-3 text-xs">
|
||||
<Smartphone className="mr-1 h-3.5 w-3.5" />
|
||||
모바일
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 저장 */}
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasChanges}
|
||||
>
|
||||
{/* Undo/Redo 버튼 */}
|
||||
<div className="flex items-center gap-1 border-r pr-2 mr-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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" />
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
|
|
@ -309,40 +625,78 @@ export default function PopDesigner({
|
|||
|
||||
{/* 메인 영역 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||
{/* 왼쪽: 패널 */}
|
||||
<ResizablePanel
|
||||
defaultSize={20}
|
||||
minSize={15}
|
||||
maxSize={30}
|
||||
className="border-r"
|
||||
>
|
||||
<PopPanel
|
||||
layout={layout}
|
||||
activeModeKey={activeModeKey}
|
||||
selectedComponentId={selectedComponentId}
|
||||
selectedComponent={selectedComponent}
|
||||
onUpdateComponentDefinition={handleUpdateComponentDefinition}
|
||||
onDeleteComponent={handleDeleteComponent}
|
||||
activeDevice={activeDevice}
|
||||
/>
|
||||
{/* 왼쪽: 컴포넌트 패널 */}
|
||||
<ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="border-r">
|
||||
{layoutMode === "v3" ? (
|
||||
<PopPanel
|
||||
layout={layoutV3}
|
||||
activeModeKey={activeModeKey}
|
||||
selectedComponentId={selectedComponentId}
|
||||
selectedComponent={selectedComponentV3}
|
||||
onUpdateComponentDefinition={handleUpdateComponentDefinitionV3}
|
||||
onDeleteComponent={handleDeleteComponentV3}
|
||||
activeDevice={activeDevice}
|
||||
/>
|
||||
) : (
|
||||
<ComponentPaletteV4 />
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 오른쪽: 캔버스 */}
|
||||
<ResizablePanel defaultSize={80}>
|
||||
<PopCanvas
|
||||
layout={layout}
|
||||
activeDevice={activeDevice}
|
||||
activeModeKey={activeModeKey}
|
||||
onModeKeyChange={handleModeKeyChange}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onSelectComponent={setSelectedComponentId}
|
||||
onUpdateComponentPosition={handleUpdateComponentPosition}
|
||||
onDropComponent={handleDropComponent}
|
||||
onDeleteComponent={handleDeleteComponent}
|
||||
/>
|
||||
{/* 중앙: 캔버스 */}
|
||||
<ResizablePanel defaultSize={layoutMode === "v3" ? 80 : 60}>
|
||||
{layoutMode === "v3" ? (
|
||||
<PopCanvas
|
||||
layout={layoutV3}
|
||||
activeDevice={activeDevice}
|
||||
activeModeKey={activeModeKey}
|
||||
onModeKeyChange={handleModeKeyChange}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onSelectComponent={setSelectedComponentId}
|
||||
onUpdateComponentPosition={handleUpdateComponentPositionV3}
|
||||
onDropComponent={handleDropComponentV3}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</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
|
||||
export { PopPanel } from "./PopPanel";
|
||||
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: {
|
||||
id: "root",
|
||||
type: "stack",
|
||||
direction: "vertical",
|
||||
wrap: false,
|
||||
direction: "horizontal", // 가로 방향 (왼쪽→오른쪽)
|
||||
wrap: true, // 자동 줄바꿈 활성화
|
||||
gap: 8,
|
||||
alignItems: "stretch",
|
||||
justifyContent: "start",
|
||||
alignItems: "start", // 위쪽 정렬
|
||||
justifyContent: "start", // 왼쪽 정렬
|
||||
padding: 16,
|
||||
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용)
|
||||
// ========================================
|
||||
|
|
@ -369,7 +601,7 @@ export interface PopComponentDefinition {
|
|||
|
||||
/**
|
||||
* POP 컴포넌트 타입
|
||||
* - 6개 핵심 컴포넌트
|
||||
* - 7개 핵심 컴포넌트 (Spacer 포함)
|
||||
*/
|
||||
export type PopComponentType =
|
||||
| "pop-field" // 데이터 입력/표시
|
||||
|
|
@ -377,7 +609,8 @@ export type PopComponentType =
|
|||
| "pop-list" // 데이터 목록 (카드 템플릿 포함)
|
||||
| "pop-indicator" // 상태/수치 표시
|
||||
| "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 컴포넌트
|
||||
|
||||
---
|
||||
|
||||
## [2026-02-04]
|
||||
## [2026-02-04] Flexbox 가로 배치 + Spacer + Undo/Redo 개선
|
||||
|
||||
### 오늘 목표
|
||||
POP 화면을 만들 수 있는 환경 완성 (타입 + 렌더러 + 기본 컴포넌트)
|
||||
### Added
|
||||
- **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
|
||||
- **v4 타입 정의** (간결 버전)
|
||||
|
|
@ -26,10 +166,21 @@ POP 화면을 만들 수 있는 환경 완성 (타입 + 렌더러 + 기본 컴
|
|||
- `PopGlobalSettingsV4` - 전역 설정
|
||||
- `createEmptyPopLayoutV4()` - 생성 함수
|
||||
- `isV4Layout()` - 타입 가드
|
||||
- CRUD 함수들 (add, remove, update, find)
|
||||
|
||||
### 진행중
|
||||
- v4 렌더러 (`PopFlexRenderer`)
|
||||
- 기본 컴포넌트 (PopButton, PopInput, PopLabel)
|
||||
- **PopFlexRenderer.tsx** - v4 Flexbox 렌더러
|
||||
- 컨테이너 재귀 렌더링
|
||||
- 반응형 규칙 적용
|
||||
- 크기 제약 → 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 타입 정의** - 완료
|
||||
2. **v4 렌더러** - Flexbox로 화면에 표시
|
||||
3. **기본 컴포넌트** - 실제 배치할 요소들
|
||||
4. **디자이너 UI 연결** - v4 모드로 설계 가능
|
||||
1. **v4 기본 구조** - 완료
|
||||
2. **v4 렌더러** - 완료 (PopFlexRenderer)
|
||||
3. **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] `PopContainerV4` - 스택 컨테이너
|
||||
|
|
@ -32,59 +105,70 @@
|
|||
- [x] `PopResponsiveRuleV4` - 반응형 규칙
|
||||
- [x] `createEmptyPopLayoutV4()` - 생성 함수
|
||||
- [x] `isV4Layout()` - 타입 가드
|
||||
- [x] `addComponentToV4Layout()` - 컴포넌트 추가
|
||||
- [x] `removeComponentFromV4Layout()` - 컴포넌트 삭제
|
||||
- [x] `updateComponentInV4Layout()` - 컴포넌트 수정
|
||||
- [x] `updateContainerV4()` - 컨테이너 수정
|
||||
- [x] `findContainerV4()` - 컨테이너 찾기
|
||||
|
||||
### v4 렌더러 (진행)
|
||||
### v4 렌더러
|
||||
|
||||
- [ ] `PopFlexRenderer` - Flexbox 기반 렌더링
|
||||
- [ ] 컨테이너 재귀 렌더링
|
||||
- [ ] 반응형 규칙 적용 (breakpoint)
|
||||
- [ ] 컴포넌트 숨김 처리 (hideBelow)
|
||||
- [x] `PopFlexRenderer` - Flexbox 기반 렌더링
|
||||
- [x] 컨테이너 재귀 렌더링 (`ContainerRenderer`)
|
||||
- [x] 반응형 규칙 적용 (`applyResponsiveRules`)
|
||||
- [x] 컴포넌트 숨김 처리 (`hideBelow`)
|
||||
- [x] 크기 제약 → CSS 변환 (`calculateSizeStyle`)
|
||||
- [x] 드래그 리사이즈 핸들 (`ComponentRendererV4`)
|
||||
- [x] 드래그 앤 드롭 순서 변경 (`DraggableComponentWrapper`)
|
||||
|
||||
### 기본 컴포넌트 (대기)
|
||||
### v4 캔버스
|
||||
|
||||
- [ ] `PopButton` - 터치 버튼
|
||||
- [ ] `PopInput` - 텍스트 입력
|
||||
- [ ] `PopLabel` - 텍스트 표시
|
||||
- [x] `PopCanvasV4` - v4 전용 캔버스
|
||||
- [x] 뷰포트 프리셋 (4개 모드)
|
||||
- [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 (기존) | v4 (새로운) |
|
||||
|---|---|---|
|
||||
| 항목 | v3 (기존) | v4 (새로운) |
|
||||
|------|-----------|-------------|
|
||||
| 설계 | 4모드 각각 | 1번만 |
|
||||
| 데이터 | col, row 위치 | 규칙 (fill/fixed/hug) |
|
||||
| 렌더링 | CSS Grid | Flexbox |
|
||||
| 반응형 | 수동 | 자동 + 규칙 |
|
||||
| 새 화면 | - | 기본 적용 |
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 로드맵
|
||||
## 관련 파일
|
||||
|
||||
### Tier 1: Primitive (기본)
|
||||
|
||||
| 컴포넌트 | 용도 | 상태 |
|
||||
|----------|------|------|
|
||||
| PopButton | 터치 버튼 | 대기 |
|
||||
| PopInput | 텍스트 입력 | 대기 |
|
||||
| PopLabel | 텍스트 표시 | 대기 |
|
||||
| PopBadge | 상태 배지 | 계획 |
|
||||
|
||||
### Tier 2: Compound (조합)
|
||||
|
||||
| 컴포넌트 | 용도 | 상태 |
|
||||
|----------|------|------|
|
||||
| PopFormField | 라벨 + 입력 | 계획 |
|
||||
| PopCard | 카드 컨테이너 | 계획 |
|
||||
| PopListItem | 목록 항목 | 계획 |
|
||||
|
||||
### Tier 3: Complex (복합)
|
||||
|
||||
| 컴포넌트 | 용도 | 상태 |
|
||||
|----------|------|------|
|
||||
| PopScanner | 바코드/QR 스캔 | 계획 |
|
||||
| PopNumpad | 숫자 키패드 | 계획 |
|
||||
| PopDataList | 페이징 목록 | 계획 |
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `PopDesigner.tsx` | v3/v4 통합 디자이너 |
|
||||
| `PopCanvasV4.tsx` | v4 캔버스 (4개 프리셋 + 슬라이더) |
|
||||
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 |
|
||||
| `ComponentPaletteV4.tsx` | v4 컴포넌트 팔레트 |
|
||||
| `ComponentEditorPanelV4.tsx` | v4 속성 편집 패널 |
|
||||
| `pop-layout.ts` | v3/v4 타입 정의 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
### 총 컴포넌트 수: 13개
|
||||
### 총 컴포넌트 수: 14개
|
||||
|
||||
| 분류 | 개수 | 컴포넌트 |
|
||||
|------|------|----------|
|
||||
| 레이아웃 | 2 | container, tab-panel |
|
||||
| 레이아웃 | 3 | container, tab-panel, **spacer** |
|
||||
| 데이터 표시 | 4 | data-table, card-list, kpi-gauge, status-indicator |
|
||||
| 입력 | 4 | number-pad, barcode-scanner, form-field, action-button |
|
||||
| 특화 기능 | 3 | timer, alarm-list, process-flow |
|
||||
|
|
@ -37,17 +37,18 @@
|
|||
|---|----------|------|
|
||||
| 1 | pop-container | 레이아웃 뼈대 |
|
||||
| 2 | pop-tab-panel | 정보 분류 |
|
||||
| 3 | pop-data-table | 대량 데이터 |
|
||||
| 4 | pop-card-list | 시각적 목록 |
|
||||
| 5 | pop-kpi-gauge | 목표 달성률 |
|
||||
| 6 | pop-status-indicator | 상태 표시 |
|
||||
| 7 | pop-number-pad | 수량 입력 |
|
||||
| 8 | pop-barcode-scanner | 스캔 입력 |
|
||||
| 9 | pop-form-field | 범용 입력 |
|
||||
| 10 | pop-action-button | 작업 실행 |
|
||||
| 11 | pop-timer | 시간 측정 |
|
||||
| 12 | pop-alarm-list | 알람 관리 |
|
||||
| 13 | pop-process-flow | 공정 현황 |
|
||||
| 3 | **pop-spacer** | **빈 공간 (정렬용)** |
|
||||
| 4 | pop-data-table | 대량 데이터 |
|
||||
| 5 | pop-card-list | 시각적 목록 |
|
||||
| 6 | pop-kpi-gauge | 목표 달성률 |
|
||||
| 7 | pop-status-indicator | 상태 표시 |
|
||||
| 8 | pop-number-pad | 수량 입력 |
|
||||
| 9 | pop-barcode-scanner | 스캔 입력 |
|
||||
| 10 | pop-form-field | 범용 입력 |
|
||||
| 11 | pop-action-button | 작업 실행 |
|
||||
| 12 | pop-timer | 시간 측정 |
|
||||
| 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
|
||||
|
||||
역할: 대량 데이터 표시, 선택, 편집
|
||||
|
|
|
|||
Loading…
Reference in New Issue