From 5f23c13490e629a79206362663af9431893339dd Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 4 Feb 2026 18:23:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop):=20=20-=20=EB=AA=A8=EB=93=9C=EB=B3=84?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=91=9C=EC=8B=9C/?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=EB=B0=8F=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20visibility=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=EB=B3=84=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C/=EC=88=A8=EA=B9=80=20=EC=A0=9C=EC=96=B4=20pop-break?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20Flexbox=20?= =?UTF-8?q?=EA=B0=95=EC=A0=9C=20=EC=A4=84=EB=B0=94=EA=BF=88=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20(flex-basis:=20100%)=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=98=A4=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(=EB=AA=A8=EB=93=9C=EB=B3=84=20=EC=84=A4=EC=A0=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EA=B0=80=EB=8A=A5)=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=98=A4=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A0=95=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=86=8D=EC=84=B1=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EC=97=90=20"=ED=91=9C=EC=8B=9C"=20=ED=83=AD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20UI)=20?= =?UTF-8?q?=ED=8C=94=EB=A0=88=ED=8A=B8=EC=97=90=20"=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88"=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20popdocs=20=EB=AC=B8=EC=84=9C=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20(PHASE3=5FSUMMARY,=20decisions/002,=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/pop/designer/PopCanvasV4.tsx | 75 +- .../components/pop/designer/PopDesigner.tsx | 130 +++- .../panels/ComponentEditorPanelV4.tsx | 150 +++- .../designer/panels/ComponentPaletteV4.tsx | 7 + .../designer/renderers/PopFlexRenderer.tsx | 134 +++- .../pop/designer/types/pop-layout.ts | 111 ++- popdocs/CHANGELOG.md | 198 ++++- popdocs/PHASE3_SUMMARY.md | 518 +++++++++++++ popdocs/PLAN.md | 45 +- popdocs/README.md | 36 +- popdocs/V4_UNIFIED_DESIGN_SPEC.md | 107 ++- popdocs/components-spec.md | 171 ++++- .../decisions/002-phase3-visibility-break.md | 690 ++++++++++++++++++ 13 files changed, 2268 insertions(+), 104 deletions(-) create mode 100644 popdocs/PHASE3_SUMMARY.md create mode 100644 popdocs/decisions/002-phase3-visibility-break.md diff --git a/frontend/components/pop/designer/PopCanvasV4.tsx b/frontend/components/pop/designer/PopCanvasV4.tsx index 396cf7ad..dc00c2d9 100644 --- a/frontend/components/pop/designer/PopCanvasV4.tsx +++ b/frontend/components/pop/designer/PopCanvasV4.tsx @@ -11,7 +11,7 @@ import { 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 { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, RotateCcw, Lock } from "lucide-react"; import { Button } from "@/components/ui/button"; import { PopFlexRenderer } from "./renderers/PopFlexRenderer"; @@ -37,6 +37,9 @@ interface PopCanvasV4Props { layout: PopLayoutDataV4; selectedComponentId: string | null; selectedContainerId: string | null; + currentMode: ViewportPreset; // 현재 모드 + tempLayout?: PopContainerV4 | null; // 임시 레이아웃 (고정 전 미리보기) + onModeChange: (mode: ViewportPreset) => void; // 모드 변경 onSelectComponent: (id: string | null) => void; onSelectContainer: (id: string | null) => void; onDropComponent: (type: PopComponentType, containerId: string) => void; @@ -45,6 +48,8 @@ interface PopCanvasV4Props { onDeleteComponent: (componentId: string) => void; onResizeComponent?: (componentId: string, size: Partial) => void; onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void; + onLockLayout?: () => void; // 배치 고정 + onResetOverride?: (mode: ViewportPreset) => void; // 오버라이드 초기화 } // ======================================== @@ -58,6 +63,9 @@ export function PopCanvasV4({ layout, selectedComponentId, selectedContainerId, + currentMode, + tempLayout, + onModeChange, onSelectComponent, onSelectContainer, onDropComponent, @@ -66,13 +74,12 @@ export function PopCanvasV4({ onDeleteComponent, onResizeComponent, onReorderComponent, + onLockLayout, + onResetOverride, }: PopCanvasV4Props) { // 줌 상태 const [canvasScale, setCanvasScale] = useState(0.8); - // 현재 뷰포트 프리셋 (기본: 태블릿 가로) - const [activeViewport, setActiveViewport] = useState(DEFAULT_PRESET); - // 커스텀 뷰포트 크기 (슬라이더) const [customWidth, setCustomWidth] = useState(1024); const [customHeight, setCustomHeight] = useState(768); @@ -85,7 +92,7 @@ export function PopCanvasV4({ const dropRef = useRef(null); // 현재 뷰포트 해상도 - const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === activeViewport)!; + const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; const viewportWidth = customWidth; const viewportHeight = customHeight; @@ -96,7 +103,7 @@ export function PopCanvasV4({ // 뷰포트 프리셋 변경 const handleViewportChange = (preset: ViewportPreset) => { - setActiveViewport(preset); + onModeChange(preset); // 부모에게 알림 const presetData = VIEWPORT_PRESETS.find((p) => p.id === preset)!; setCustomWidth(presetData.width); setCustomHeight(presetData.height); @@ -176,6 +183,20 @@ export function PopCanvasV4({ drop(dropRef); + // 오버라이드 상태 확인 + const hasOverride = (mode: ViewportPreset): boolean => { + if (mode === DEFAULT_PRESET) return false; // 기본 모드는 오버라이드 없음 + + const override = layout.overrides?.[mode as keyof typeof layout.overrides]; + if (!override) return false; + + // 컴포넌트 또는 컨테이너 오버라이드가 있으면 true + const hasComponentOverrides = override.components && Object.keys(override.components).length > 0; + const hasContainerOverrides = override.containers && Object.keys(override.containers).length > 0; + + return !!(hasComponentOverrides || hasContainerOverrides); + }; + return (
{/* 툴바 */} @@ -185,16 +206,19 @@ export function PopCanvasV4({ 미리보기: {VIEWPORT_PRESETS.map((preset) => { const Icon = preset.icon; - const isActive = activeViewport === preset.id; + const isActive = currentMode === preset.id; const isDefault = preset.id === DEFAULT_PRESET; + const isEdited = hasOverride(preset.id); + return ( ); })}
+ {/* 고정 버튼 (기본 모드가 아닐 때 표시) */} + {currentMode !== DEFAULT_PRESET && onLockLayout && ( + + )} + + {/* 오버라이드 초기화 버튼 (편집된 모드에만 표시) */} + {hasOverride(currentMode) && onResetOverride && ( + + )} + {/* 줌 컨트롤 */}
@@ -291,6 +346,8 @@ export function PopCanvasV4({ ("tablet"); const [activeModeKey, setActiveModeKey] = useState("tablet_landscape"); + // ======================================== + // v4용 뷰포트 모드 상태 + // ======================================== + type ViewportMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; + const [currentViewportMode, setCurrentViewportMode] = useState("tablet_landscape"); + + // v4: 임시 레이아웃 (고정 전 배치) - 다른 모드에서만 사용 + const [tempLayout, setTempLayout] = useState(null); + // ======================================== // 선택 상태 // ======================================== @@ -354,15 +363,23 @@ export default function PopDesigner({ const handleUpdateContainerV4 = useCallback( (containerId: string, updates: Partial) => { - const newLayout = { - ...layoutV4, - root: updateContainerV4(layoutV4.root, containerId, updates), - }; - setLayoutV4(newLayout); - saveToHistoryV4(newLayout); - setHasChanges(true); + if (currentViewportMode === "tablet_landscape") { + // 기본 모드 (태블릿 가로) → root 직접 수정 ✅ + const newLayout = { + ...layoutV4, + root: updateContainerV4(layoutV4.root, containerId, updates), + }; + setLayoutV4(newLayout); + saveToHistoryV4(newLayout); + setHasChanges(true); + console.log("[기본 모드] root 컨테이너 수정"); + } else { + // 다른 모드 → 속성 패널에서 수정 차단됨 (UI에서 비활성화) + toast.warning("기본 모드(태블릿 가로)에서만 속성을 변경할 수 있습니다"); + console.log("[다른 모드] 속성 수정 차단"); + } }, - [layoutV4, saveToHistoryV4] + [layoutV4, currentViewportMode, saveToHistoryV4] ); const handleDeleteComponentV4 = useCallback((componentId: string) => { @@ -374,6 +391,70 @@ export default function PopDesigner({ console.log("[V4] 컴포넌트 삭제, 히스토리 저장됨"); }, [layoutV4, saveToHistoryV4]); + // v4: 현재 모드 배치 고정 (오버라이드 저장) 🔥 + const handleLockLayoutV4 = useCallback(() => { + if (currentViewportMode === "tablet_landscape") { + toast.info("기본 모드는 고정할 필요가 없습니다"); + return; + } + + if (!tempLayout) { + toast.info("변경사항이 없습니다"); + return; + } + + // 임시 레이아웃을 오버라이드에 저장 ✅ + const newLayout = { + ...layoutV4, + overrides: { + ...layoutV4.overrides, + [currentViewportMode]: { + ...layoutV4.overrides?.[currentViewportMode as keyof typeof layoutV4.overrides], + containers: { + root: { + direction: tempLayout.direction, + wrap: tempLayout.wrap, + gap: tempLayout.gap, + alignItems: tempLayout.alignItems, + justifyContent: tempLayout.justifyContent, + padding: tempLayout.padding, + children: tempLayout.children, // 순서 고정 + } + } + } + } + }; + + setLayoutV4(newLayout); + saveToHistoryV4(newLayout); + setTempLayout(null); // 임시 레이아웃 초기화 + setHasChanges(true); + toast.success(`${currentViewportMode} 모드 배치가 고정되었습니다`); + console.log(`[V4] ${currentViewportMode} 배치 고정됨 (tempLayout → overrides)`); + }, [layoutV4, currentViewportMode, tempLayout, saveToHistoryV4]); + + // v4: 오버라이드 초기화 (자동 계산으로 되돌리기) + const handleResetOverrideV4 = useCallback((mode: ViewportMode) => { + if (mode === "tablet_landscape") { + toast.info("기본 모드는 초기화할 수 없습니다"); + return; + } + + const newOverrides = { ...layoutV4.overrides }; + delete newOverrides[mode as keyof typeof newOverrides]; + + const newLayout = { + ...layoutV4, + overrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined + }; + + setLayoutV4(newLayout); + saveToHistoryV4(newLayout); + setHasChanges(true); + toast.success(`${mode} 모드 오버라이드가 초기화되었습니다`); + console.log(`[V4] ${mode} 오버라이드 초기화됨`); + }, [layoutV4, saveToHistoryV4]); + // v4: 컴포넌트 크기 조정 (드래그) - 리사이즈 중에는 히스토리 저장 안 함 // 리사이즈 완료 시 별도로 저장해야 함 (TODO: 드래그 종료 시 저장) const handleResizeComponentV4 = useCallback( @@ -426,16 +507,25 @@ export default function PopDesigner({ }; }; - const newLayout = { - ...layoutV4, - root: reorderInContainer(layoutV4.root), - }; - setLayoutV4(newLayout); - saveToHistoryV4(newLayout); - setHasChanges(true); - console.log("[V4] 컴포넌트 순서 변경", { containerId, fromIndex, toIndex }); + if (currentViewportMode === "tablet_landscape") { + // 기본 모드 → root 직접 수정 ✅ + const newLayout = { + ...layoutV4, + root: reorderInContainer(layoutV4.root), + }; + setLayoutV4(newLayout); + saveToHistoryV4(newLayout); + setHasChanges(true); + console.log("[기본 모드] 컴포넌트 순서 변경 (root 저장)"); + } else { + // 다른 모드 → 임시 레이아웃에만 저장 (화면에만 표시, layoutV4는 안 건드림) 🔥 + const reorderedRoot = reorderInContainer(layoutV4.root); + setTempLayout(reorderedRoot); + console.log(`[${currentViewportMode}] 컴포넌트 순서 변경 (임시, 고정 필요)`); + toast.info("배치 변경됨. '고정' 버튼을 클릭하여 저장하세요", { duration: 2000 }); + } }, - [layoutV4, saveToHistoryV4] + [layoutV4, currentViewportMode, saveToHistoryV4] ); // ======================================== @@ -663,6 +753,9 @@ export default function PopDesigner({ layout={layoutV4} selectedComponentId={selectedComponentId} selectedContainerId={selectedContainerId} + currentMode={currentViewportMode} + tempLayout={tempLayout} + onModeChange={setCurrentViewportMode} onSelectComponent={setSelectedComponentId} onSelectContainer={setSelectedContainerId} onDropComponent={handleDropComponentV4} @@ -671,6 +764,8 @@ export default function PopDesigner({ onDeleteComponent={handleDeleteComponentV4} onResizeComponent={handleResizeComponentV4} onReorderComponent={handleReorderComponentV4} + onLockLayout={handleLockLayoutV4} + onResetOverride={handleResetOverrideV4} /> )} @@ -683,6 +778,7 @@ export default function PopDesigner({ handleUpdateComponentV4(selectedComponentId, updates) diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx index dd601566..4df98ff1 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx @@ -17,6 +17,7 @@ import { Square, Maximize2, AlignCenter, + Eye, } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; @@ -39,6 +40,8 @@ interface ComponentEditorPanelV4Props { component: PopComponentDefinitionV4 | null; /** 선택된 컨테이너 */ container: PopContainerV4 | null; + /** 현재 뷰포트 모드 */ + currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; /** 컴포넌트 업데이트 */ onUpdateComponent?: (updates: Partial) => void; /** 컨테이너 업데이트 */ @@ -57,6 +60,8 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-indicator": "인디케이터", "pop-scanner": "스캐너", "pop-numpad": "숫자패드", + "pop-spacer": "스페이서", + "pop-break": "줄바꿈", }; // ======================================== @@ -71,6 +76,7 @@ const COMPONENT_TYPE_LABELS: Record = { export function ComponentEditorPanelV4({ component, container, + currentViewportMode = "tablet_landscape", onUpdateComponent, onUpdateContainer, className, @@ -91,15 +97,23 @@ export function ComponentEditorPanelV4({ // 컨테이너가 선택된 경우 if (container) { + const isNonDefaultMode = currentViewportMode !== "tablet_landscape"; + return (

컨테이너 설정

{container.id}

+ {isNonDefaultMode && ( +

+ 다른 모드에서는 드래그로 배치 변경 후 '고정' 버튼을 사용하세요 +

+ )}
@@ -129,6 +143,10 @@ export function ComponentEditorPanelV4({ 설정 + + + 표시 + 데이터 @@ -151,6 +169,14 @@ export function ComponentEditorPanelV4({ /> + {/* 모드별 표시 탭 */} + + + + {/* 데이터 바인딩 탭 */} @@ -359,25 +385,6 @@ function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) {
- - {/* 반응형 숨김 */} -
- -
- - onUpdate?.({ - hideBelow: e.target.value ? Number(e.target.value) : undefined, - }) - } - placeholder="없음" - /> - px 이하에서 숨김 -
-
); } @@ -416,13 +423,17 @@ function SizeButton({ active, onClick, label, description }: SizeButtonProps) { interface ContainerSettingsFormProps { container: PopContainerV4; + currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; onUpdate?: (updates: Partial) => void; } function ContainerSettingsForm({ container, + currentViewportMode = "tablet_landscape", onUpdate, }: ContainerSettingsFormProps) { + const isNonDefaultMode = currentViewportMode !== "tablet_landscape"; + return (
{/* 방향 */} @@ -434,6 +445,7 @@ function ContainerSettingsForm({ size="sm" className="flex-1 h-8 text-xs" onClick={() => onUpdate?.({ direction: "horizontal" })} + disabled={isNonDefaultMode} > 가로 @@ -442,10 +454,16 @@ function ContainerSettingsForm({ size="sm" className="flex-1 h-8 text-xs" onClick={() => onUpdate?.({ direction: "vertical" })} + disabled={isNonDefaultMode} > 세로
+ {isNonDefaultMode && ( +

+ 드래그로 배치 변경 후 '고정' 버튼 클릭 +

+ )} {/* 줄바꿈 */} @@ -457,6 +475,7 @@ function ContainerSettingsForm({ size="sm" className="flex-1 h-8 text-xs" onClick={() => onUpdate?.({ wrap: true })} + disabled={isNonDefaultMode} > 허용 @@ -465,6 +484,7 @@ function ContainerSettingsForm({ size="sm" className="flex-1 h-8 text-xs" onClick={() => onUpdate?.({ wrap: false })} + disabled={isNonDefaultMode} > 금지 @@ -480,6 +500,7 @@ function ContainerSettingsForm({ className="h-8 w-24 text-xs" value={container.gap} onChange={(e) => onUpdate?.({ gap: Number(e.target.value) || 0 })} + disabled={isNonDefaultMode} /> px @@ -496,6 +517,7 @@ function ContainerSettingsForm({ onChange={(e) => onUpdate?.({ padding: Number(e.target.value) || undefined }) } + disabled={isNonDefaultMode} /> px @@ -507,6 +529,7 @@ function ContainerSettingsForm({ onUpdate?.({ justifyContent: value as any })} + disabled={isNonDefaultMode} > @@ -581,6 +605,94 @@ function ComponentSettingsForm({ ); } +// ======================================== +// 모드별 표시/숨김 폼 +// ======================================== + +interface VisibilityFormProps { + component: PopComponentDefinitionV4; + onUpdate?: (updates: Partial) => void; +} + +function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { + const modes = [ + { key: "tablet_landscape" as const, label: "태블릿 가로 (1024×768)" }, + { key: "tablet_portrait" as const, label: "태블릿 세로 (768×1024)" }, + { key: "mobile_landscape" as const, label: "모바일 가로 (667×375)" }, + { key: "mobile_portrait" as const, label: "모바일 세로 (375×667)" }, + ]; + + return ( +
+
+ +

+ 체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다 +

+ +
+ {modes.map(({ key, label }) => { + const isChecked = component.visibility?.[key] !== false; + + return ( +
+ { + onUpdate?.({ + visibility: { + ...component.visibility, + [key]: e.target.checked, + }, + }); + }} + className="h-4 w-4 rounded border-gray-300" + /> + + {!isChecked && ( + (숨김) + )} +
+ ); + })} +
+
+ + {/* 반응형 숨김 (픽셀 기반) */} +
+ +
+ + onUpdate?.({ + hideBelow: e.target.value ? Number(e.target.value) : undefined, + }) + } + placeholder="없음" + /> + px 이하에서 숨김 +
+

+ 예: 500 입력 시 화면 너비가 500px 이하면 자동 숨김 +

+
+
+ ); +} + // ======================================== // 데이터 바인딩 플레이스홀더 // ======================================== diff --git a/frontend/components/pop/designer/panels/ComponentPaletteV4.tsx b/frontend/components/pop/designer/panels/ComponentPaletteV4.tsx index 40d15726..2346d454 100644 --- a/frontend/components/pop/designer/panels/ComponentPaletteV4.tsx +++ b/frontend/components/pop/designer/panels/ComponentPaletteV4.tsx @@ -10,6 +10,7 @@ import { Calculator, GripVertical, Space, + WrapText, } from "lucide-react"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; @@ -66,6 +67,12 @@ const COMPONENT_PALETTE: { icon: Space, description: "빈 공간 (정렬용)", }, + { + type: "pop-break", + label: "줄바꿈", + icon: WrapText, + description: "강제 줄바꿈 (flex-basis: 100%)", + }, ]; // ======================================== diff --git a/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx b/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx index 9da420d8..313de675 100644 --- a/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx @@ -31,6 +31,10 @@ interface PopFlexRendererProps { layout: PopLayoutDataV4; /** 현재 뷰포트 너비 (반응형 규칙 적용용) */ viewportWidth: number; + /** 현재 뷰포트 모드 (오버라이드 병합용) */ + currentMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; + /** 임시 레이아웃 (고정 전 미리보기) */ + tempLayout?: PopContainerV4 | null; /** 디자인 모드 여부 */ isDesignMode?: boolean; /** 선택된 컴포넌트 ID */ @@ -60,6 +64,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-scanner": "스캐너", "pop-numpad": "숫자패드", "pop-spacer": "스페이서", + "pop-break": "줄바꿈", }; // ======================================== @@ -74,6 +79,8 @@ const COMPONENT_TYPE_LABELS: Record = { export function PopFlexRenderer({ layout, viewportWidth, + currentMode = "tablet_landscape", + tempLayout, isDesignMode = false, selectedComponentId, onComponentClick, @@ -83,10 +90,60 @@ export function PopFlexRenderer({ onReorderComponent, className, }: PopFlexRendererProps) { - const { root, components, settings } = layout; + const { root, components, settings, overrides } = layout; + + // 오버라이드 병합 로직 (컨테이너) 🔥 + const getMergedRoot = (): PopContainerV4 => { + // 1. 임시 레이아웃이 있으면 최우선 (고정 전 미리보기) + if (tempLayout) { + return tempLayout; + } + + // 2. 기본 모드면 root 그대로 반환 + if (currentMode === "tablet_landscape") { + return root; + } + + // 3. 다른 모드면 오버라이드 병합 + const override = overrides?.[currentMode]?.containers?.root; + if (override) { + return { + ...root, + ...override, // 오버라이드 속성으로 덮어쓰기 + }; + } + + // 4. 오버라이드 없으면 기본값 + return root; + }; + + // visibility 체크 함수 🆕 + const isComponentVisible = (component: PopComponentDefinitionV4): boolean => { + if (!component.visibility) return true; // 기본값: 표시 + const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility]; + return modeVisibility !== false; // undefined도 true로 취급 + }; + + // 컴포넌트 오버라이드 병합 🆕 + const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => { + if (currentMode === "tablet_landscape") return baseComponent; + + const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id]; + if (!componentOverride) return baseComponent; + + // 깊은 병합 (config, size) + return { + ...baseComponent, + ...componentOverride, + size: { ...baseComponent.size, ...componentOverride.size }, + config: { ...baseComponent.config, ...componentOverride.config }, + }; + }; + + const effectiveRoot = getMergedRoot(); // 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링 - if (root.children.length === 0) { + if (effectiveRoot.children.length === 0) { return (
- {/* 루트 컨테이너 렌더링 */} + {/* 루트 컨테이너 렌더링 (병합된 레이아웃 사용) */} ; viewportWidth: number; settings: PopLayoutDataV4["settings"]; + currentMode: string; + overrides: PopLayoutDataV4["overrides"]; isDesignMode?: boolean; selectedComponentId?: string | null; onComponentClick?: (componentId: string) => void; @@ -144,6 +205,8 @@ function ContainerRenderer({ components, viewportWidth, settings, + currentMode, + overrides, isDesignMode = false, selectedComponentId, onComponentClick, @@ -152,6 +215,28 @@ function ContainerRenderer({ onReorderComponent, depth = 0, }: ContainerRendererProps) { + // visibility 체크 함수 + const isComponentVisible = (component: PopComponentDefinitionV4): boolean => { + if (!component.visibility) return true; // 기본값: 표시 + const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility]; + return modeVisibility !== false; // undefined도 true로 취급 + }; + + // 컴포넌트 오버라이드 병합 + const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => { + if (currentMode === "tablet_landscape") return baseComponent; + + const componentOverride = overrides?.[currentMode as keyof typeof overrides]?.components?.[baseComponent.id]; + if (!componentOverride) return baseComponent; + + // 깊은 병합 (config, size) + return { + ...baseComponent, + ...componentOverride, + size: { ...baseComponent.size, ...componentOverride.size }, + config: { ...baseComponent.config, ...componentOverride.config }, + }; + }; // 반응형 규칙 적용 const effectiveContainer = useMemo(() => { return applyResponsiveRules(container, viewportWidth); @@ -209,6 +294,8 @@ function ContainerRenderer({ components={components} viewportWidth={viewportWidth} settings={settings} + currentMode={currentMode} + overrides={overrides} isDesignMode={isDesignMode} selectedComponentId={selectedComponentId} onComponentClick={onComponentClick} @@ -222,14 +309,43 @@ function ContainerRenderer({ // 컴포넌트 ID인 경우 const componentId = child; - const compDef = components[componentId]; - if (!compDef) return null; + const baseComponent = components[componentId]; + if (!baseComponent) return null; - // 반응형 숨김 처리 - if (compDef.hideBelow && viewportWidth < compDef.hideBelow) { + // visibility 체크 (모드별 숨김) + if (!isComponentVisible(baseComponent)) { return null; } + // 반응형 숨김 처리 (픽셀 기반) + if (baseComponent.hideBelow && viewportWidth < baseComponent.hideBelow) { + return null; + } + + // 오버라이드 병합 + const mergedComponent = getMergedComponent(baseComponent); + + // pop-break 특수 처리 + if (mergedComponent.type === "pop-break") { + return ( +
onComponentClick?.(componentId)} + > + {isDesignMode && ( + 줄바꿈 + )} +
+ ); + } + return ( >; + + // 컨테이너별 오버라이드 + containers?: Record>; } /** @@ -93,9 +116,17 @@ export interface PopComponentDefinitionV4 { // 개별 정렬 (컨테이너 설정 덮어쓰기) alignSelf?: "start" | "center" | "end" | "stretch"; - // 반응형 숨김 + // 반응형 숨김 (픽셀 기반) hideBelow?: number; // 이 너비 이하에서 숨김 + // 모드별 표시/숨김 (명시적) + visibility?: { + tablet_landscape?: boolean; // 기본값 true + tablet_portrait?: boolean; + mobile_landscape?: boolean; + mobile_portrait?: boolean; + }; + // 기존 속성 dataBinding?: PopDataBinding; style?: PopStylePreset; @@ -217,6 +248,11 @@ export const createComponentDefinitionV4 = ( height: "fixed", fixedHeight: 48, }, + "pop-break": { + width: "fill", // 100% 너비로 줄바꿈 강제 (flex-basis: 100%) + height: "fixed", + fixedHeight: 0, // 높이 0 (보이지 않음) + }, }; return { @@ -292,23 +328,79 @@ function addChildToContainer( /** * v4 레이아웃에서 컴포넌트 삭제 + * - components에서 삭제 + * - root.children에서 제거 + * - 모든 overrides에서도 제거 (중요!) */ export const removeComponentFromV4Layout = ( layout: PopLayoutDataV4, componentId: string ): PopLayoutDataV4 => { - const newLayout = { ...layout }; + // 1. 컴포넌트 정의 삭제 + const { [componentId]: _, ...remainingComponents } = layout.components; - // 컴포넌트 정의 삭제 - const { [componentId]: _, ...remainingComponents } = newLayout.components; - newLayout.components = remainingComponents; + // 2. root.children에서 제거 + const newRoot = removeChildFromContainer(layout.root, componentId); - // 컨테이너에서 컴포넌트 ID 제거 - newLayout.root = removeChildFromContainer(newLayout.root, componentId); + // 3. 모든 오버라이드에서 제거 + const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId); - return newLayout; + return { + ...layout, + root: newRoot, + components: remainingComponents, + overrides: newOverrides, + }; }; +/** + * 오버라이드 정리 (컴포넌트 삭제 시) + * - containers.root.children에서 제거 + * - components에서 제거 + */ +function cleanupOverridesAfterDelete( + overrides: PopLayoutDataV4["overrides"], + componentId: string +): PopLayoutDataV4["overrides"] { + if (!overrides) return undefined; + + const newOverrides = { ...overrides }; + + for (const mode of Object.keys(newOverrides) as Array) { + const override = newOverrides[mode]; + if (!override) continue; + + const updated = { ...override }; + + // containers.root.children에서 제거 + if (updated.containers?.root?.children) { + updated.containers = { + ...updated.containers, + root: { + ...updated.containers.root, + children: updated.containers.root.children.filter(id => id !== componentId), + }, + }; + } + + // components에서 제거 + if (updated.components?.[componentId]) { + const { [componentId]: _, ...rest } = updated.components; + updated.components = Object.keys(rest).length > 0 ? rest : undefined; + } + + // 빈 오버라이드 정리 + if (!updated.containers && !updated.components) { + delete newOverrides[mode]; + } else { + newOverrides[mode] = updated; + } + } + + // 모든 오버라이드가 비었으면 undefined 반환 + return Object.keys(newOverrides).length > 0 ? newOverrides : undefined; +} + /** * 컨테이너에서 자식 제거 (재귀) */ @@ -610,7 +702,8 @@ export type PopComponentType = | "pop-indicator" // 상태/수치 표시 | "pop-scanner" // 바코드/QR 입력 | "pop-numpad" // 숫자 입력 특화 - | "pop-spacer"; // 빈 공간 (레이아웃 정렬용) + | "pop-spacer" // 빈 공간 (레이아웃 정렬용) + | "pop-break"; // 줄바꿈 (강제 줄바꿈, flex-basis: 100%) // ======================================== // 데이터 흐름 diff --git a/popdocs/CHANGELOG.md b/popdocs/CHANGELOG.md index d8ae8ef9..73eb7169 100644 --- a/popdocs/CHANGELOG.md +++ b/popdocs/CHANGELOG.md @@ -6,13 +6,110 @@ ## [미출시] -- Phase 2: 모드별 오버라이드 기능 +- Phase 2: 모드별 오버라이드 기능 (진행 중) - Phase 3: 컴포넌트 표시/숨김 - Phase 4: 순서 오버라이드 - Tier 2, 3 컴포넌트 --- +## [2026-02-04] Phase 2.1 완료 - 배치 고정 기능 + +### Added +- **현재 모드 추적** (PopDesigner.tsx) + - `currentViewportMode` 상태 추가 + - PopCanvasV4와 양방향 동기화 + - 모드 변경 시 자동 업데이트 + +- **배치 고정 기능** + - "고정" 버튼 추가 (기본 모드 제외) + - `handleLockLayoutV4()` - 현재 배치를 오버라이드에 저장 + - 배치 정보: direction, wrap, gap, alignItems, justifyContent, children 순서 + +- **오버라이드 초기화 기능** + - `handleResetOverrideV4()` - 오버라이드 삭제 + - "자동으로 되돌리기" 버튼 (편집된 모드만 표시) + - 자동 계산으로 되돌림 + +### Changed +- **PopCanvasV4 Props 구조 변경** + - `currentMode` prop 추가 (외부에서 제어) + - `onModeChange` 콜백 추가 + - `onLockLayout` 콜백 추가 + - 내부 `activeViewport` 상태 제거 (부모가 관리) + +- **프리셋 버튼 동작** + - 클릭 시 부모 상태 업데이트 (`onModeChange`) + - `currentMode` prop 기반으로 활성 상태 표시 + +### Technical Details +```typescript +// 고정 로직 +const handleLockLayoutV4 = () => { + const newLayout = { + ...layoutV4, + overrides: { + ...layoutV4.overrides, + [currentViewportMode]: { + containers: { + root: { + direction: layoutV4.root.direction, + wrap: layoutV4.root.wrap, + gap: layoutV4.root.gap, + children: layoutV4.root.children, // 순서 고정 + // ... 기타 배치 속성 + } + } + } + } + }; +}; + +// 초기화 로직 +const handleResetOverrideV4 = (mode) => { + const newOverrides = { ...layoutV4.overrides }; + delete newOverrides[mode]; + // overrides가 비면 undefined로 설정 +}; +``` + +### UI 변경 +``` +툴바: +[모바일↕] [모바일↔] [태블릿↕] [태블릿↔(기본)] [고정] [자동으로 되돌리기] + +조건부 표시: +- "고정" 버튼: 기본 모드가 아닐 때 +- "자동으로 되돌리기": 오버라이드가 있을 때 +``` + +### 주의사항 +- 크기는 고정하지 않음 (여전히 자동 스케일링) +- 배치만 오버라이드 (순서, 방향, 정렬) +- 최소/최대값 기능은 별도 구현 필요 + +--- + +## [2026-02-04] Phase 2 시작 - 오버라이드 UI 표시 + +### Added +- **오버라이드 데이터 구조** (pop-layout.ts) + - `PopModeOverride` 인터페이스 추가 + - `PopLayoutDataV4.overrides` 필드 추가 + - 3개 모드 오버라이드 지원 (mobile_portrait, mobile_landscape, tablet_portrait) + +- **프리셋 버튼 상태 표시** (PopCanvasV4.tsx) + - 기본 모드: "(기본)" 텍스트 표시 + - 편집된 모드: "(편집)" 텍스트 + 노란색 강조 + - 자동 모드: 기본 스타일 + +### Changed +- **hasOverride 함수 구현** + - `layout.overrides` 필드 체크 + - 컴포넌트/컨테이너 오버라이드 존재 여부 확인 + +--- + ## [2026-02-04] 비율 스케일링 시스템 구현 ### Added @@ -202,6 +299,105 @@ v4를 기본 레이아웃 모드로 통합하고, 새 화면은 자동으로 v4 --- +## [2026-02-04] Phase 2.1 완료 - 배치 고정 기능 (버그 수정) + +### 🔥 주요 버그 수정 +- **layoutV4.root 오염 문제 해결**: 다른 모드에서 편집 시 기본 레이아웃이 변경되던 버그 수정 +- **tempLayout 도입**: 고정 전 임시 배치를 별도 상태로 관리하여 root를 보호 +- **렌더러 병합 로직**: `PopFlexRenderer`에 오버라이드 자동 병합 기능 추가 + +### 데이터 흐름 개선 +1. **기본 모드 (태블릿 가로)** + - 드래그/속성 변경 → `layoutV4.root` 직접 수정 ✅ + - 모든 다른 모드의 기본값으로 사용 + +2. **다른 모드 (모바일 세로 등)** + - 드래그 → `tempLayout` 임시 저장 (화면에만 표시) + - "고정" 버튼 → `layoutV4.overrides[mode]`에 저장 + - 속성 패널 → 비활성화 + 안내 메시지 + +3. **렌더링** + - `tempLayout` 있으면 최우선 표시 (고정 전 미리보기) + - 오버라이드 있으면 `root`와 병합 + - 없으면 `root` 그대로 표시 + +### 수정 파일 +- `PopDesigner.tsx`: tempLayout 상태 추가, 핸들러 수정 +- `PopFlexRenderer.tsx`: 병합 로직 추가 (getMergedRoot) +- `PopCanvasV4.tsx`: tempLayout props 전달 +- `ComponentEditorPanelV4.tsx`: 속성 패널 비활성화 로직 + +--- + +## [2026-02-04] Phase 3 완료 - visibility + 줄바꿈 컴포넌트 + +### 추가 기능 +- **visibility 속성**: 모드별 컴포넌트 표시/숨김 제어 +- **pop-break 컴포넌트**: 강제 줄바꿈 (flex-basis: 100%) +- **컴포넌트 오버라이드 병합**: 모드별 컴포넌트 설정 변경 가능 + +### 타입 정의 +```typescript +interface PopComponentDefinitionV4 { + // 기존 속성... + + // 🆕 모드별 표시/숨김 + visibility?: { + tablet_landscape?: boolean; + tablet_portrait?: boolean; + mobile_landscape?: boolean; + mobile_portrait?: boolean; + }; +} + +// 🆕 줄바꿈 컴포넌트 +type PopComponentType = + | "pop-field" + | "pop-button" + | "pop-list" + | "pop-indicator" + | "pop-scanner" + | "pop-numpad" + | "pop-spacer" + | "pop-break"; // 새로 추가 +``` + +### 렌더러 개선 +- `isComponentVisible()`: visibility 체크 로직 +- `getMergedComponent()`: 컴포넌트 오버라이드 병합 +- pop-break 전용 렌더링 (디자인 모드: 점선, 실제: 높이 0) + +### 삭제 함수 개선 +- `cleanupOverridesAfterDelete()`: 컴포넌트 삭제 시 모든 오버라이드 정리 +- containers.root.children 정리 +- components 오버라이드 정리 +- 빈 오버라이드 자동 제거 + +### UI 개선 +- 속성 패널에 "표시" 탭 추가 (Eye 아이콘) +- 모드별 체크박스 UI +- 반응형 숨김 (hideBelow) 유지 +- 팔레트에 "줄바꿈" 컴포넌트 추가 + +### 사용 예시 +``` +태블릿 가로: +[A] [B] [C] [D] [E] ← 한 줄 + +모바일 세로: +[A] [B] +─────── ← 줄바꿈 (visibility: mobile만 true) +[C] [D] [E] +``` + +### 수정 파일 +- `pop-layout.ts`: 타입 추가, 삭제 함수 수정 +- `PopFlexRenderer.tsx`: visibility, 병합, pop-break 렌더링 +- `ComponentEditorPanelV4.tsx`: 표시 탭 추가 +- `ComponentPaletteV4.tsx`: 줄바꿈 추가 + +--- + ## [2026-02-04] v4 타입 및 렌더러 ### Added diff --git a/popdocs/PHASE3_SUMMARY.md b/popdocs/PHASE3_SUMMARY.md new file mode 100644 index 00000000..4c08ae56 --- /dev/null +++ b/popdocs/PHASE3_SUMMARY.md @@ -0,0 +1,518 @@ +# Phase 3 완료 요약 + +**날짜**: 2026-02-04 +**상태**: 완료 ✅ +**버전**: v4.0 Phase 3 + +--- + +## 🎯 달성 목표 + +Phase 2의 배치 고정 기능 이후, 다음 3가지 핵심 기능 추가: + +1. ✅ **모드별 컴포넌트 표시/숨김** (visibility) +2. ✅ **강제 줄바꿈 컴포넌트** (pop-break) +3. ✅ **컴포넌트 오버라이드 병합** (모드별 설정 변경) + +--- + +## 📦 구현 내용 + +### 1. 타입 정의 + +**파일**: `frontend/components/pop/designer/types/pop-layout.ts` + +```typescript +// pop-break 추가 +export type PopComponentType = + | "pop-field" + | "pop-button" + | "pop-list" + | "pop-indicator" + | "pop-scanner" + | "pop-numpad" + | "pop-spacer" + | "pop-break"; // 🆕 + +// visibility 속성 추가 +export interface PopComponentDefinitionV4 { + id: string; + type: PopComponentType; + size: PopSizeConstraintV4; + + visibility?: { + tablet_landscape?: boolean; + tablet_portrait?: boolean; + mobile_landscape?: boolean; + mobile_portrait?: boolean; + }; + + // ... +} + +// 기본 크기 +defaultSizes["pop-break"] = { + width: "fill", // 100% 너비 + height: "fixed", + fixedHeight: 0, // 높이 0 +}; +``` + +--- + +### 2. 렌더러 로직 + +**파일**: `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx` + +#### visibility 체크 +```typescript +const isComponentVisible = (component: PopComponentDefinitionV4): boolean => { + if (!component.visibility) return true; // 기본값: 표시 + const modeVisibility = component.visibility[currentMode]; + return modeVisibility !== false; // undefined도 true로 취급 +}; +``` + +#### 컴포넌트 오버라이드 병합 +```typescript +const getMergedComponent = (baseComponent: PopComponentDefinitionV4) => { + if (currentMode === "tablet_landscape") return baseComponent; + + const override = overrides?.[currentMode]?.components?.[baseComponent.id]; + if (!override) return baseComponent; + + // 깊은 병합 (config, size) + return { + ...baseComponent, + ...override, + size: { ...baseComponent.size, ...override.size }, + config: { ...baseComponent.config, ...override.config }, + }; +}; +``` + +#### pop-break 렌더링 +```typescript +if (mergedComponent.type === "pop-break") { + return ( +
+ {isDesignMode && 줄바꿈} +
+ ); +} +``` + +--- + +### 3. 삭제 함수 개선 + +**파일**: `frontend/components/pop/designer/types/pop-layout.ts` + +```typescript +export const removeComponentFromV4Layout = ( + layout: PopLayoutDataV4, + componentId: string +): PopLayoutDataV4 => { + // 1. components에서 삭제 + const { [componentId]: _, ...remainingComponents } = layout.components; + + // 2. root.children에서 제거 + const newRoot = removeChildFromContainer(layout.root, componentId); + + // 3. 🆕 모든 오버라이드에서 제거 + const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId); + + return { + ...layout, + root: newRoot, + components: remainingComponents, + overrides: newOverrides, + }; +}; +``` + +#### 오버라이드 정리 로직 +```typescript +function cleanupOverridesAfterDelete( + overrides: PopLayoutDataV4["overrides"], + componentId: string +) { + // 각 모드별로: + // 1. containers.root.children에서 componentId 제거 + // 2. components[componentId] 제거 + // 3. 빈 오버라이드 자동 삭제 +} +``` + +--- + +### 4. 속성 패널 UI + +**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx` + +#### "표시" 탭 추가 +```typescript + + 크기 + 설정 + + + 표시 + + 데이터 + +``` + +#### VisibilityForm 컴포넌트 +```typescript +function VisibilityForm({ component, onUpdate }) { + const modes = [ + { key: "tablet_landscape", label: "태블릿 가로" }, + { key: "tablet_portrait", label: "태블릿 세로" }, + { key: "mobile_landscape", label: "모바일 가로" }, + { key: "mobile_portrait", label: "모바일 세로" }, + ]; + + return ( +
+ {modes.map(({ key, label }) => ( + { + onUpdate?.({ + visibility: { + ...component.visibility, + [key]: e.target.checked, + }, + }); + }} + /> + ))} +
+ ); +} +``` + +--- + +### 5. 팔레트 업데이트 + +**파일**: `frontend/components/pop/designer/panels/ComponentPaletteV4.tsx` + +```typescript +const COMPONENT_PALETTE = [ + // ... 기존 컴포넌트들 + { + type: "pop-break", + label: "줄바꿈", + icon: WrapText, + description: "강제 줄바꿈 (flex-basis: 100%)", + }, +]; +``` + +--- + +## 🎨 UI 변경사항 + +### 컴포넌트 팔레트 +``` +컴포넌트 +├─ 필드 +├─ 버튼 +├─ 리스트 +├─ 인디케이터 +├─ 스캐너 +├─ 숫자패드 +├─ 스페이서 +└─ 줄바꿈 🆕 +``` + +### 속성 패널 +``` +┌─────────────────────┐ +│ 탭: [크기][설정] │ +│ [표시📍][데이터] │ +├─────────────────────┤ +│ 모드별 표시 설정 │ +│ ☑ 태블릿 가로 │ +│ ☑ 태블릿 세로 │ +│ ☐ 모바일 가로 (숨김)│ +│ ☑ 모바일 세로 │ +├─────────────────────┤ +│ 반응형 숨김 │ +│ [500] px 이하 숨김 │ +└─────────────────────┘ +``` + +--- + +## 📖 사용 예시 + +### 예시 1: 모바일 전용 버튼 +```typescript +{ + id: "call-button", + type: "pop-button", + label: "전화 걸기", + visibility: { + tablet_landscape: false, // 태블릿: 숨김 + tablet_portrait: false, + mobile_landscape: true, // 모바일: 표시 + mobile_portrait: true, + }, +} +``` + +**결과**: +- 태블릿 화면: "전화 걸기" 버튼 안 보임 +- 모바일 화면: "전화 걸기" 버튼 보임 + +--- + +### 예시 2: 모드별 줄바꿈 +```typescript +레이아웃: [A] [B] [줄바꿈] [C] [D] + +줄바꿈 설정: +{ + id: "break-1", + type: "pop-break", + visibility: { + tablet_landscape: false, // 태블릿: 줄바꿈 숨김 + mobile_portrait: true, // 모바일: 줄바꿈 표시 + } +} +``` + +**결과**: +``` +태블릿 가로 (1024px): +┌───────────────────────────┐ +│ [A] [B] [C] [D] │ ← 한 줄 +└───────────────────────────┘ + +모바일 세로 (375px): +┌─────────────────┐ +│ [A] [B] │ ← 첫 줄 +│ [C] [D] │ ← 둘째 줄 (줄바꿈 적용) +└─────────────────┘ +``` + +--- + +### 예시 3: 리스트 컬럼 수 변경 (확장 가능) +```typescript +// 기본 (태블릿 가로) +{ + id: "product-list", + type: "pop-list", + config: { + columns: 7, // 7개 컬럼 + } +} + +// 오버라이드 (모바일 세로) +overrides: { + mobile_portrait: { + components: { + "product-list": { + config: { + columns: 3, // 3개 컬럼 + } + } + } + } +} +``` + +**결과**: +- 태블릿: 7개 컬럼 표시 +- 모바일: 3개 컬럼 표시 (자동 병합) + +--- + +## 🧪 테스트 시나리오 + +### ✅ 테스트 1: 줄바꿈 기본 동작 +1. 팔레트에서 "줄바꿈" 드래그 +2. 컴포넌트 사이에 드롭 +3. 디자인 모드에서 점선 "줄바꿈" 표시 확인 +4. 미리보기에서 줄바꿈이 안 보이는지 확인 + +### ✅ 테스트 2: 모드별 줄바꿈 +1. 줄바꿈 컴포넌트 추가 +2. "표시" 탭 → 태블릿 모드 체크 해제 +3. 태블릿 가로: 한 줄 +4. 모바일 세로: 두 줄 + +### ✅ 테스트 3: 삭제 시 오버라이드 정리 +1. 모바일 세로에서 배치 고정 +2. 컴포넌트 삭제 +3. 저장 후 로드 +4. DB 확인: overrides에서도 제거되었는지 + +### ✅ 테스트 4: 컴포넌트 숨김 +1. 컴포넌트 선택 +2. "표시" 탭 → 태블릿 모드 체크 해제 +3. 태블릿: 컴포넌트 안 보임 +4. 모바일: 컴포넌트 보임 + +### ✅ 테스트 5: 속성 패널 UI +1. 컴포넌트 선택 +2. "표시" 탭 클릭 +3. 4개 체크박스 확인 +4. 체크 해제 시 "(숨김)" 표시 +5. 저장 후 로드 → 상태 유지 + +--- + +## 📝 수정된 파일 + +### 코드 파일 (5개) +``` +✅ frontend/components/pop/designer/types/pop-layout.ts + - PopComponentType 확장 (pop-break) + - PopComponentDefinitionV4.visibility 추가 + - cleanupOverridesAfterDelete() 추가 + +✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx + - isComponentVisible() 추가 + - getMergedComponent() 추가 + - pop-break 렌더링 추가 + - ContainerRenderer props 확장 + +✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx + - "표시" 탭 추가 + - VisibilityForm 컴포넌트 추가 + - COMPONENT_TYPE_LABELS 업데이트 + +✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx + - "줄바꿈" 컴포넌트 추가 + +✅ frontend/components/pop/designer/PopDesigner.tsx + - (기존 Phase 2 변경사항 유지) +``` + +### 문서 파일 (6개) +``` +✅ popdocs/CHANGELOG.md + - Phase 3 완료 기록 + +✅ popdocs/PLAN.md + - Phase 3 체크 완료 + - Phase 4 계획 추가 + +✅ popdocs/V4_UNIFIED_DESIGN_SPEC.md + - Phase 3 섹션 추가 + +✅ popdocs/components-spec.md + - pop-break 상세 스펙 추가 + - Phase 3 업데이트 노트 + +✅ popdocs/README.md + - 현재 상태 업데이트 + - Phase 3 요약 추가 + +✅ popdocs/decisions/002-phase3-visibility-break.md (신규) + - 상세 설계 문서 + +✅ popdocs/PHASE3_SUMMARY.md (신규) + - 이 문서 +``` + +--- + +## 🎓 핵심 개념 + +### Flexbox 줄바꿈 원리 +```css +/* 컨테이너 */ +.container { + display: flex; + flex-direction: row; + flex-wrap: wrap; /* 필수 */ +} + +/* pop-break */ +.pop-break { + flex-basis: 100%; /* 전체 너비 차지 → 다음 요소는 새 줄로 */ + height: 0; /* 실제로는 안 보임 */ +} +``` + +### visibility vs hideBelow +| 속성 | 제어 방식 | 용도 | +|------|----------|------| +| `visibility` | 모드별 명시적 | 특정 모드에서만 표시 (예: 모바일 전용) | +| `hideBelow` | 픽셀 기반 자동 | 화면 너비에 따라 자동 숨김 (예: 500px 이하) | + +**예시**: +```typescript +{ + visibility: { + tablet_landscape: false, // 태블릿 가로: 무조건 숨김 + }, + hideBelow: 500, // 500px 이하: 자동 숨김 (다른 모드에서도) +} +``` + +--- + +## 🚀 다음 단계 + +### Phase 4: 실제 컴포넌트 구현 +``` +우선순위: +1. pop-field (입력/표시 필드) +2. pop-button (액션 버튼) +3. pop-list (데이터 리스트) +4. pop-indicator (KPI 표시) +5. pop-scanner (바코드/QR) +6. pop-numpad (숫자 입력) +``` + +### 추가 개선 사항 +``` +1. 컴포넌트 오버라이드 UI + - 리스트 컬럼 수 조정 UI + - 버튼 스타일 변경 UI + - 필드 표시 형식 변경 UI + +2. "모든 모드에 적용" 기능 + - 한 번에 모든 모드 체크/해제 + +3. 오버라이드 비교 뷰 + - 기본값 vs 오버라이드 차이 시각화 +``` + +--- + +## ✨ 주요 성과 + +1. ✅ **모드별 컴포넌트 제어**: visibility 속성으로 유연한 표시/숨김 +2. ✅ **Flexbox 줄바꿈 해결**: pop-break 컴포넌트로 업계 표준 달성 +3. ✅ **확장 가능한 구조**: 컴포넌트 오버라이드 병합으로 추후 기능 추가 용이 +4. ✅ **데이터 일관성**: 삭제 시 오버라이드 자동 정리로 데이터 무결성 유지 +5. ✅ **직관적인 UI**: 체크박스 기반 visibility 제어 + +--- + +## 📚 참고 문서 + +- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계 +- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - v4 통합 설계 +- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력 +- [PLAN.md](./PLAN.md) - 로드맵 + +--- + +*Phase 3 완료 - 2026-02-04* +*다음: Phase 4 (실제 컴포넌트 구현)* diff --git a/popdocs/PLAN.md b/popdocs/PLAN.md index ba903951..976211b2 100644 --- a/popdocs/PLAN.md +++ b/popdocs/PLAN.md @@ -86,23 +86,44 @@ v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 → - [x] 디자인 모드 분리 (scale=1, 원본 유지) - [x] DndProvider 에러 수정 (뷰어에서 useDrag/useDrop 방지) -## Phase 2: 오버라이드 기능 (다음) +## Phase 2: 오버라이드 기능 (완료) ✅ -- [ ] ModeOverride 데이터 구조 추가 -- [ ] 편집 감지 → 자동 오버라이드 저장 -- [ ] 편집 상태 표시 (버튼 색상 변경) -- [ ] "자동으로 되돌리기" 버튼 +### Phase 2.1: 배치 고정 (완료) +- [x] 현재 모드 추적 (PopDesigner) +- [x] 고정 버튼 UI 및 로직 +- [x] 오버라이드 저장 (배치만) +- [x] 오버라이드 초기화 로직 +- [x] **버그 수정**: tempLayout 도입 (root 오염 방지) +- [x] **속성 패널**: 다른 모드에서 비활성화 -## Phase 3: 컴포넌트 표시/숨김 (계획) +### Phase 2.2: 렌더러 오버라이드 적용 (완료) ✅ +- [x] PopFlexRenderer에서 오버라이드 병합 (getMergedRoot) +- [x] 컨테이너 속성 오버라이드 적용 (direction, wrap, gap, alignItems, justifyContent, padding, children) +- [x] tempLayout 우선 표시 (고정 전 미리보기) +- [x] 테스트 (모드별 다른 배치) -- [ ] visibility 속성 추가 (모드별 true/false) -- [ ] 속성 패널 체크박스 UI -- [ ] 렌더러에서 visibility 처리 +### Phase 2.3: 편집 자동 감지 (완료) ✅ +- [x] 순서 변경 시 자동 tempLayout 저장 +- [x] "고정" 버튼으로 정식 오버라이드 전환 +- [x] 속성 변경은 기본 모드에서만 가능 (다른 모드 차단) -## Phase 4: 순서 오버라이드 (계획) +## Phase 3: 컴포넌트 표시/숨김 + 줄바꿈 (완료) ✅ -- [ ] 모드별 children 순서 오버라이드 -- [ ] 드래그로 순서 변경 UI +- [x] visibility 속성 추가 (모드별 true/false) +- [x] 속성 패널 "표시" 탭 추가 (체크박스 UI) +- [x] 렌더러에서 visibility 처리 +- [x] pop-break 컴포넌트 추가 (강제 줄바꿈) +- [x] 컴포넌트 오버라이드 병합 로직 +- [x] 삭제 시 오버라이드 정리 로직 + +## Phase 4: 실제 컴포넌트 구현 (다음) + +- [ ] pop-field: 입력/표시 필드 +- [ ] pop-button: 액션 버튼 +- [ ] pop-list: 데이터 리스트 (카드 템플릿) +- [ ] pop-indicator: KPI/상태 표시 +- [ ] pop-scanner: 바코드/QR 스캔 +- [ ] pop-numpad: 숫자 입력 패드 --- diff --git a/popdocs/README.md b/popdocs/README.md index 4b6173a2..54301756 100644 --- a/popdocs/README.md +++ b/popdocs/README.md @@ -24,8 +24,9 @@ ### 현재 상태 -- **버전**: v3.0 (4모드 그리드) -- **다음**: v4.0 (제약조건 기반) - 계획 +- **버전**: v4.0 (제약조건 기반) ✅ +- **Phase**: Phase 3 완료 (visibility + 줄바꿈) +- **다음**: Phase 4 (실제 컴포넌트 구현) --- @@ -34,6 +35,7 @@ | 파일 | 용도 | |------|------| | [SPEC.md](./SPEC.md) | 기술 스펙 | +| [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) | v4 통합 설계 모드 | | [PLAN.md](./PLAN.md) | 계획/로드맵 | | [CHANGELOG.md](./CHANGELOG.md) | 변경 이력 | | [decisions/](./decisions/) | 중요 결정 기록 (ADR) | @@ -77,14 +79,38 @@ v3: 4개 모드 각각 위치 설정 → 4배 작업량 v4: 3가지 규칙만 설정 → 자동 적응 -규칙: +핵심 규칙: 1. 크기: fixed(고정) / fill(채움) / hug(맞춤) 2. 배치: direction, wrap, gap 3. 반응형: breakpoint별 변경 + +Phase 3 추가: +4. visibility: 모드별 표시/숨김 +5. pop-break: 강제 줄바꿈 ``` -상세: [SPEC.md](./SPEC.md) +상세: [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) --- -*최종 업데이트: 2026-02-04* +## Phase 3 완료 사항 (2026-02-04) ✅ + +### 새 기능 +- **visibility 속성**: 모드별 컴포넌트 표시/숨김 제어 +- **pop-break 컴포넌트**: Flexbox 강제 줄바꿈 (`flex-basis: 100%`) +- **컴포넌트 오버라이드 병합**: 모드별 설정 변경 (리스트 컬럼 수 등) +- **오버라이드 정리 로직**: 컴포넌트 삭제 시 모든 오버라이드 자동 정리 + +### 사용 예시 +``` +태블릿: [A] [B] [C] [D] (한 줄) +모바일: [A] [B] (두 줄, 줄바꿈 적용) + [C] [D] +``` + +### 참고 +- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계 + +--- + +*최종 업데이트: 2026-02-04 (Phase 3 완료)* diff --git a/popdocs/V4_UNIFIED_DESIGN_SPEC.md b/popdocs/V4_UNIFIED_DESIGN_SPEC.md index ade03edd..98520b36 100644 --- a/popdocs/V4_UNIFIED_DESIGN_SPEC.md +++ b/popdocs/V4_UNIFIED_DESIGN_SPEC.md @@ -1,7 +1,8 @@ # POP v4 통합 설계 모드 스펙 **작성일: 2026-02-04** -**상태: Phase 1.6 완료 (비율 스케일링 시스템)** +**최종 업데이트: 2026-02-04** +**상태: Phase 3 완료 (visibility + 줄바꿈 컴포넌트)** --- @@ -52,12 +53,12 @@ v3/v4 탭을 제거하고, **v4 자동 모드를 기본**으로 하되 **모드 │ │ [태블릿↕][태블릿↔(기본)] │ │ │ 필드 │ 너비: [====●====] 1024 x 768 │ │ │ 버튼 │ │ │ -│ 리스트 │ ┌──────────────────────────────┐ │ │ -│ 인디케이터 │ │ [필드1] [필드2] [필드3] │ │ │ -│ 스캐너 │ │ [필드4] [Spacer] [버튼] │ │ │ -│ 숫자패드 │ │ │ │ │ +│ 리스트 │ ┌──────────────────────────────┐ │ 탭: 크기 │ +│ 인디케이터 │ │ [필드1] [필드2] [필드3] │ │ 설정 │ +│ 스캐너 │ │ [필드4] [Spacer] [버튼] │ │ 표시 ⬅ 🆕│ +│ 숫자패드 │ │ │ │ 데이터 │ │ 스페이서 │ │ (가로 배치 + 자동 줄바꿈) │ │ │ -│ │ │ (스크롤 가능) │ │ │ +│ 줄바꿈 🆕 │ │ (스크롤 가능) │ │ │ │ │ └──────────────────────────────┘ │ │ │ │ 태블릿 가로 (1024x768) │ │ └────────────┴────────────────────────────────────┴───────────────┘ @@ -73,14 +74,24 @@ v3/v4 탭을 제거하고, **v4 자동 모드를 기본**으로 하되 **모드 | Adalo 2.0 | Flexbox + Constraints | | **POP v4** | **Flexbox (horizontal + wrap)** | -### Spacer 컴포넌트 사용법 +### 특수 컴포넌트 사용법 +#### Spacer (빈 공간) ``` [버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로 [Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로 [Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로 ``` +#### 줄바꿈 (Break) 🆕 Phase 3 +``` +[필드A] [필드B] [줄바꿈] [필드C] → 필드C가 새 줄로 이동 + +태블릿: [필드A] [필드B] [필드C] ← 줄바꿈 숨김 (한 줄) +모바일: [필드A] [필드B] ← 줄바꿈 표시 (두 줄) + [필드C] +``` + ### 프리셋 버튼 (4개 모드) | 버튼 | 해상도 | 설명 | @@ -296,5 +307,85 @@ scaledPadding = originalPadding * scale --- +## Phase 3: Visibility + 줄바꿈 컴포넌트 (완료) ✅ + +### 개요 +모드별 컴포넌트 표시/숨김 제어 및 강제 줄바꿈 기능 추가. + +### 추가 타입 + +#### visibility 속성 +```typescript +interface PopComponentDefinitionV4 { + // 기존 속성... + + // 🆕 모드별 표시/숨김 + visibility?: { + tablet_landscape?: boolean; + tablet_portrait?: boolean; + mobile_landscape?: boolean; + mobile_portrait?: boolean; + }; +} +``` + +#### pop-break 컴포넌트 +```typescript +type PopComponentType = + | "pop-field" + | "pop-button" + | "pop-list" + | "pop-indicator" + | "pop-scanner" + | "pop-numpad" + | "pop-spacer" + | "pop-break"; // 🆕 줄바꿈 +``` + +### 사용 예시 + +#### 모바일 전용 버튼 +```typescript +{ + id: "call-button", + type: "pop-button", + label: "전화 걸기", + visibility: { + tablet_landscape: false, // 태블릿: 숨김 + mobile_portrait: true, // 모바일: 표시 + }, +} +``` + +#### 모드별 줄바꿈 +``` +레이아웃: [A] [B] [줄바꿈] [C] [D] + +줄바꿈 visibility: { tablet_landscape: false, mobile_portrait: true } + +결과: +태블릿: [A] [B] [C] [D] (한 줄) +모바일: [A] [B] (두 줄) + [C] [D] +``` + +### 속성 패널 "표시" 탭 +``` +┌─────────────────────┐ +│ 탭: 크기 설정 표시 📍│ +├─────────────────────┤ +│ 모드별 표시 설정 │ +│ ☑ 태블릿 가로 │ +│ ☑ 태블릿 세로 │ +│ ☐ 모바일 가로 (숨김)│ +│ ☑ 모바일 세로 │ +└─────────────────────┘ +``` + +### 참고 문서 +- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계 + +--- + *이 문서는 v4 통합 설계 모드의 스펙을 정의합니다.* -*최종 업데이트: 2026-02-04* +*최종 업데이트: 2026-02-04 (Phase 3 완료)* diff --git a/popdocs/components-spec.md b/popdocs/components-spec.md index a3fd66a5..c96a482f 100644 --- a/popdocs/components-spec.md +++ b/popdocs/components-spec.md @@ -6,11 +6,11 @@ ## Quick Reference -### 총 컴포넌트 수: 14개 +### 총 컴포넌트 수: 15개 (🆕 줄바꿈 추가) | 분류 | 개수 | 컴포넌트 | |------|------|----------| -| 레이아웃 | 3 | container, tab-panel, **spacer** | +| 레이아웃 | 4 | container, tab-panel, **spacer**, **break 🆕** | | 데이터 표시 | 4 | data-table, card-list, kpi-gauge, status-indicator | | 입력 | 4 | number-pad, barcode-scanner, form-field, action-button | | 특화 기능 | 3 | timer, alarm-list, process-flow | @@ -38,17 +38,18 @@ | 1 | pop-container | 레이아웃 뼈대 | | 2 | pop-tab-panel | 정보 분류 | | 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 | 공정 현황 | +| 4 | **pop-break 🆕** | **강제 줄바꿈 (Flexbox)** | +| 5 | pop-data-table | 대량 데이터 | +| 6 | pop-card-list | 시각적 목록 | +| 7 | pop-kpi-gauge | 목표 달성률 | +| 8 | pop-status-indicator | 상태 표시 | +| 9 | pop-number-pad | 수량 입력 | +| 10 | pop-barcode-scanner | 스캔 입력 | +| 11 | pop-form-field | 범용 입력 | +| 12 | pop-action-button | 작업 실행 | +| 13 | pop-timer | 시간 측정 | +| 14 | pop-alarm-list | 알람 관리 | +| 15 | pop-process-flow | 공정 현황 | --- @@ -109,7 +110,107 @@ --- -## 3. pop-data-table +## 4. pop-break (v4 전용) 🆕 + +역할: Flexbox에서 강제 줄바꿈을 위한 컴포넌트 (업계 표준: Figma Auto Layout의 줄바꿈과 동일) + +| 기능 | 설명 | +|------|------| +| 강제 줄바꿈 | `flex-basis: 100%`로 다음 컴포넌트를 새 줄로 이동 | +| 모드별 표시 | visibility 속성으로 특정 모드에서만 줄바꿈 적용 | +| 시각적 표시 | 디자인 모드에서만 점선으로 표시 | +| 실제 화면 | 높이 0px (완전히 보이지 않음) | + +### 동작 원리 + +``` +Flexbox wrap: true 상태에서 +flex-basis: 100%를 가진 요소 → 전체 너비 차지 → 다음 요소는 자동으로 새 줄로 이동 +``` + +### 사용 예시 + +``` +[필드A] [필드B] [줄바꿈] [필드C] [필드D] + +결과: +┌────────────────────┐ +│ [필드A] [필드B] │ ← 첫째 줄 +│ [필드C] [필드D] │ ← 둘째 줄 (줄바꿈 후) +└────────────────────┘ +``` + +### 모드별 줄바꿈 + +```typescript +// 줄바꿈 컴포넌트 설정 +{ + id: "break-1", + type: "pop-break", + visibility: { + tablet_landscape: false, // 태블릿: 줄바꿈 숨김 (한 줄) + mobile_portrait: true, // 모바일: 줄바꿈 표시 (두 줄) + } +} + +// 결과 +태블릿: [A] [B] [C] [D] (한 줄) +모바일: [A] [B] (두 줄) + [C] [D] +``` + +### 기본 설정 + +| 속성 | 기본값 | +|------|--------| +| width | fill (`flex-basis: 100%`) | +| height | 0px (높이 없음) | +| 디자인 모드 표시 | 점선 + "줄바꿈" 텍스트 (높이 16px) | +| 실제 모드 | 완전히 투명 (높이 0px) | +| flex-basis | 100% (핵심 속성) | + +### CSS 구현 + +```css +/* 디자인 모드 */ +.pop-break-design { + flex-basis: 100%; + width: 100%; + height: 16px; + border: 2px dashed #d1d5db; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +/* 실제 모드 */ +.pop-break-runtime { + flex-basis: 100%; + width: 100%; + height: 0; +} +``` + +### 업계 비교 + +| 서비스 | 줄바꿈 방식 | +|--------|------------| +| Figma Auto Layout | "Wrap" 설정 + 수동 줄 분리 | +| Webflow Flexbox | "Wrap" + 100% width spacer | +| Framer | "Break" 컴포넌트 | +| **POP v4** | **pop-break (flex-basis: 100%)** | + +### 주의사항 + +- 컨테이너의 `wrap: true` 설정 필수 +- wrap이 false면 줄바꿈 무시됨 +- visibility로 모드별 제어 가능 +- 디자인 모드에서만 시각적으로 보임 + +--- + +## 5. pop-data-table 역할: 대량 데이터 표시, 선택, 편집 @@ -317,4 +418,44 @@ --- -*최종 업데이트: 2026-01-29* +## Phase 3 업데이트 (2026-02-04) 🆕 + +### 추가된 컴포넌트 + +#### pop-break (줄바꿈) +- **역할**: Flexbox에서 강제 줄바꿈 +- **핵심 기술**: `flex-basis: 100%` +- **모드별 제어**: visibility 속성 지원 +- **시각적 표시**: 디자인 모드에서만 점선 표시 (실제 높이 0px) + +### 모든 컴포넌트 공통 추가 속성 + +#### visibility (모드별 표시/숨김) +```typescript +visibility?: { + tablet_landscape?: boolean; + tablet_portrait?: boolean; + mobile_landscape?: boolean; + mobile_portrait?: boolean; +} +``` + +**사용 예시**: +```typescript +// 모바일 전용 버튼 +{ + type: "pop-action-button", + visibility: { + tablet_landscape: false, + mobile_portrait: true, + } +} +``` + +### 참고 문서 +- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계 +- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - v4 통합 설계 + +--- + +*최종 업데이트: 2026-02-04 (Phase 3 완료)* diff --git a/popdocs/decisions/002-phase3-visibility-break.md b/popdocs/decisions/002-phase3-visibility-break.md new file mode 100644 index 00000000..c161389d --- /dev/null +++ b/popdocs/decisions/002-phase3-visibility-break.md @@ -0,0 +1,690 @@ +# Phase 3: Visibility + 줄바꿈 컴포넌트 구현 + +**날짜**: 2026-02-04 +**상태**: 구현 완료 ✅ +**관련 이슈**: 모드별 컴포넌트 표시/숨김, 강제 줄바꿈 + +--- + +## 📋 목표 + +Phase 2의 배치 고정 기능에 이어, 다음 기능들을 추가: +1. **모드별 컴포넌트 표시/숨김** (visibility) +2. **강제 줄바꿈 컴포넌트** (pop-break) +3. **컴포넌트 오버라이드 병합** (모드별 설정 변경) + +--- + +## 🔍 문제 정의 + +### 문제 1: 모드별 컴포넌트 추가/삭제 불가 +``` +현재 상황: +- 모든 모드에서 같은 컴포넌트만 표시 가능 +- 모바일 전용 버튼(예: "전화 걸기")을 추가할 수 없음 + +요구사항: +- 특정 모드에서만 컴포넌트 표시 +- 다른 모드에서는 자동 숨김 +``` + +### 문제 2: Flexbox에서 강제 줄바꿈 불가 +``` +현재 상황: +- wrap: true여도 컴포넌트가 공간을 채워야 줄바꿈 +- [A] [B] [C] → 강제로 [A] [B] / [C] 불가능 + +요구사항: +- 사용자가 원하는 위치에서 강제 줄바꿈 +- 디자인 모드에서 시각적으로 표시 +``` + +### 문제 3: 컴포넌트 설정을 모드별로 변경 불가 +``` +현재 상황: +- 컨테이너 배치만 오버라이드 가능 +- 리스트 컬럼 수, 버튼 스타일 등은 모든 모드 동일 + +요구사항 (확장성): +- 태블릿: 리스트 7개 컬럼 +- 모바일: 리스트 3개 컬럼 +``` + +--- + +## 💡 해결 방안 + +### 방안 A: children 배열 오버라이드 (추가/삭제) +```typescript +overrides: { + mobile_portrait: { + containers: { + root: { + children: ["comp1", "comp2", "mobile-only-button"] // 컴포넌트 추가 + } + } + } +} +``` + +**장점**: +- 모드별로 완전히 다른 컴포넌트 구성 가능 +- 유연성 극대화 + +**단점**: +- 데이터 동기화 복잡 +- 삭제/추가 시 다른 모드에도 영향 +- 순서 변경 시 충돌 가능 + +--- + +### 방안 B: visibility 속성 (표시/숨김) ✅ 채택 +```typescript +interface PopComponentDefinitionV4 { + visibility?: { + tablet_landscape?: boolean; + tablet_portrait?: boolean; + mobile_landscape?: boolean; + mobile_portrait?: boolean; + }; +} +``` + +**장점**: +- 단순하고 명확 +- 컴포넌트는 항상 존재 (숨김만) +- 데이터 일관성 유지 + +**단점**: +- 완전히 다른 컴포넌트 추가는 불가능 +- 많은 모드 전용 컴포넌트는 비효율적 + +--- + +### 최종 결정: 하이브리드 접근 ⭐ +```typescript +1. visibility: 기본 기능 (Phase 3) + - 간단한 표시/숨김 + - 줄바꿈 컴포넌트 제어 + +2. components 오버라이드: 고급 기능 (Phase 3 기반) + - 컴포넌트 설정 변경 (리스트 컬럼 수 등) + - 스타일 변경 + +3. children 오버라이드: 추후 고려 + - 모드별 완전히 다른 구성 필요 시 +``` + +--- + +## 🛠️ 구현 내용 + +### 1. 타입 정의 확장 + +#### pop-break 컴포넌트 추가 +```typescript +export type PopComponentType = + | "pop-field" + | "pop-button" + | "pop-list" + | "pop-indicator" + | "pop-scanner" + | "pop-numpad" + | "pop-spacer" + | "pop-break"; // 🆕 줄바꿈 +``` + +#### visibility 속성 추가 +```typescript +export interface PopComponentDefinitionV4 { + id: string; + type: PopComponentType; + size: PopSizeConstraintV4; + + // 🆕 모드별 표시/숨김 + visibility?: { + tablet_landscape?: boolean; // undefined = true (기본 표시) + tablet_portrait?: boolean; + mobile_landscape?: boolean; + mobile_portrait?: boolean; + }; + + // 기존: 픽셀 기반 반응형 + hideBelow?: number; + + // 기타... +} +``` + +#### 기본 크기 설정 +```typescript +const defaultSizes: Record = { + // ... + "pop-break": { + width: "fill", // 100% 너비 (flex-basis: 100%) + height: "fixed", + fixedHeight: 0, // 높이 0 (보이지 않음) + }, +}; +``` + +--- + +### 2. 렌더러 로직 개선 + +#### visibility 체크 함수 +```typescript +const isComponentVisible = (component: PopComponentDefinitionV4): boolean => { + if (!component.visibility) return true; // 기본값: 표시 + + const modeVisibility = component.visibility[currentMode]; + return modeVisibility !== false; // undefined도 true로 취급 +}; +``` + +**로직 설명**: +- `visibility` 속성이 없으면 → 모든 모드에서 표시 +- `visibility.mobile_portrait === false` → 모바일 세로에서 숨김 +- `visibility.mobile_portrait === undefined` → 모바일 세로에서 표시 (기본값) + +--- + +#### 컴포넌트 오버라이드 병합 +```typescript +const getMergedComponent = ( + baseComponent: PopComponentDefinitionV4 +): PopComponentDefinitionV4 => { + if (currentMode === "tablet_landscape") return baseComponent; + + const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id]; + if (!componentOverride) return baseComponent; + + // 깊은 병합 (config, size) + return { + ...baseComponent, + ...componentOverride, + size: { ...baseComponent.size, ...componentOverride.size }, + config: { ...baseComponent.config, ...componentOverride.config }, + }; +}; +``` + +**병합 우선순위**: +1. `baseComponent` (기본값) +2. `overrides[currentMode].components[id]` (모드별 오버라이드) +3. 중첩 객체는 깊은 병합 (`size`, `config`) + +**확장 가능성**: +- 리스트 컬럼 수 변경 +- 버튼 스타일 변경 +- 필드 표시 형식 변경 + +--- + +#### pop-break 전용 렌더링 +```typescript +// pop-break 특수 처리 +if (mergedComponent.type === "pop-break") { + return ( +
onComponentClick?.(componentId)} + > + {isDesignMode && ( + 줄바꿈 + )} +
+ ); +} +``` + +**동작 방식**: +- `flex-basis: 100%` → 컨테이너 전체 너비 차지 +- 다음 컴포넌트는 자동으로 새 줄로 이동 +- 디자인 모드: 점선 표시 (높이 16px) +- 실제 화면: 높이 0 (안 보임) + +--- + +### 3. 삭제 함수 개선 + +#### 오버라이드 정리 로직 +```typescript +export const removeComponentFromV4Layout = ( + layout: PopLayoutDataV4, + componentId: string +): PopLayoutDataV4 => { + // 1. 컴포넌트 정의 삭제 + const { [componentId]: _, ...remainingComponents } = layout.components; + + // 2. root.children에서 제거 + const newRoot = removeChildFromContainer(layout.root, componentId); + + // 3. 🆕 모든 오버라이드에서 제거 + const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId); + + return { + ...layout, + root: newRoot, + components: remainingComponents, + overrides: newOverrides, + }; +}; +``` + +#### 오버라이드 정리 상세 +```typescript +function cleanupOverridesAfterDelete( + overrides: PopLayoutDataV4["overrides"], + componentId: string +): PopLayoutDataV4["overrides"] { + if (!overrides) return undefined; + + const newOverrides = { ...overrides }; + + for (const mode of Object.keys(newOverrides)) { + const override = newOverrides[mode]; + if (!override) continue; + + const updated = { ...override }; + + // containers.root.children에서 제거 + if (updated.containers?.root?.children) { + updated.containers = { + ...updated.containers, + root: { + ...updated.containers.root, + children: updated.containers.root.children.filter(id => id !== componentId), + }, + }; + } + + // components에서 제거 + if (updated.components?.[componentId]) { + const { [componentId]: _, ...rest } = updated.components; + updated.components = Object.keys(rest).length > 0 ? rest : undefined; + } + + // 빈 오버라이드 정리 + if (!updated.containers && !updated.components) { + delete newOverrides[mode]; + } else { + newOverrides[mode] = updated; + } + } + + // 모든 오버라이드가 비었으면 undefined 반환 + return Object.keys(newOverrides).length > 0 ? newOverrides : undefined; +} +``` + +**정리 항목**: +1. `overrides[mode].containers.root.children` - 컴포넌트 ID 제거 +2. `overrides[mode].components[componentId]` - 컴포넌트 설정 제거 +3. 빈 오버라이드 객체 삭제 (메모리 절약) + +--- + +### 4. 속성 패널 UI + +#### "표시" 탭 추가 +```typescript + + 크기 + 설정 + + + 표시 + + 데이터 + +``` + +#### VisibilityForm 컴포넌트 +```typescript +function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { + const modes = [ + { key: "tablet_landscape", label: "태블릿 가로 (1024×768)" }, + { key: "tablet_portrait", label: "태블릿 세로 (768×1024)" }, + { key: "mobile_landscape", label: "모바일 가로 (667×375)" }, + { key: "mobile_portrait", label: "모바일 세로 (375×667)" }, + ]; + + return ( +
+ +

+ 체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다 +

+ +
+ {modes.map(({ key, label }) => { + const isChecked = component.visibility?.[key] !== false; + + return ( +
+ { + onUpdate?.({ + visibility: { + ...component.visibility, + [key]: e.target.checked, + }, + }); + }} + /> + + {!isChecked && (숨김)} +
+ ); + })} +
+ + {/* 기존: 반응형 숨김 (픽셀 기반) */} +
+ + + onUpdate?.({ + hideBelow: e.target.value ? Number(e.target.value) : undefined, + }) + } + placeholder="없음" + /> +

+ 예: 500 입력 시 화면 너비가 500px 이하면 자동 숨김 +

+
+
+ ); +} +``` + +**UI 특징**: +- 체크박스로 직관적인 표시/숨김 제어 +- 기본값은 모든 모드 체크 (표시) +- `hideBelow` (픽셀 기반)와 별도 유지 + +--- + +### 5. 팔레트 업데이트 + +```typescript +const COMPONENT_PALETTE = [ + // ... 기존 컴포넌트들 + { + type: "pop-break", + label: "줄바꿈", + icon: WrapText, + description: "강제 줄바꿈 (flex-basis: 100%)", + }, +]; +``` + +--- + +## 🎯 사용 예시 + +### 예시 1: 모바일 전용 버튼 +```typescript +{ + id: "call-button", + type: "pop-button", + label: "전화 걸기", + size: { width: "fixed", height: "fixed", fixedWidth: 120, fixedHeight: 48 }, + visibility: { + tablet_landscape: false, // 태블릿 가로: 숨김 + tablet_portrait: false, // 태블릿 세로: 숨김 + mobile_landscape: true, // 모바일 가로: 표시 + mobile_portrait: true, // 모바일 세로: 표시 + }, +} +``` + +**결과**: +- 태블릿: "전화 걸기" 버튼 안 보임 +- 모바일: "전화 걸기" 버튼 보임 + +--- + +### 예시 2: 모드별 줄바꿈 +```typescript +레이아웃: +[필드A] [필드B] [줄바꿈] [필드C] [필드D] + +줄바꿈 컴포넌트 설정: +{ + id: "break-1", + type: "pop-break", + visibility: { + tablet_landscape: false, // 태블릿: 줄바꿈 숨김 (한 줄) + mobile_portrait: true, // 모바일: 줄바꿈 표시 (두 줄) + }, +} +``` + +**결과**: +``` +태블릿 가로 (1024px): +┌─────────────────────────────────┐ +│ [필드A] [필드B] [필드C] [필드D] │ ← 한 줄 +└─────────────────────────────────┘ + +모바일 세로 (375px): +┌─────────────────┐ +│ [필드A] [필드B] │ ← 첫 줄 +│ [필드C] [필드D] │ ← 둘째 줄 (줄바꿈 적용) +└─────────────────┘ +``` + +--- + +### 예시 3: 리스트 컬럼 수 변경 (확장 가능) +```typescript +// 기본 (태블릿 가로) +{ + id: "product-list", + type: "pop-list", + config: { + columns: 7, // 7개 컬럼 + } +} + +// 오버라이드 (모바일 세로) +overrides: { + mobile_portrait: { + components: { + "product-list": { + config: { + columns: 3, // 3개 컬럼 + } + } + } + } +} +``` + +**결과**: +- 태블릿: 7개 컬럼 표시 +- 모바일: 3개 컬럼 표시 (병합됨) + +--- + +## ✅ 테스트 시나리오 + +### 테스트 1: 줄바꿈 기본 동작 +``` +1. 팔레트에서 "줄바꿈" 드래그 +2. [A] [B] [C] 사이에 드롭 +3. 예상 결과: [A] [B] / [C] +4. 디자인 모드에서 점선 "줄바꿈" 표시 확인 +5. 미리보기에서 줄바꿈이 안 보이는지 확인 +``` + +### 테스트 2: 모드별 줄바꿈 표시 +``` +1. 줄바꿈 컴포넌트 추가 +2. "표시" 탭 → 태블릿 모드 체크 해제 +3. 태블릿 가로 모드: [A] [B] [C] (한 줄) +4. 모바일 세로 모드: [A] [B] / [C] (두 줄) +``` + +### 테스트 3: 컴포넌트 삭제 시 오버라이드 정리 +``` +1. 모바일 세로 모드에서 배치 고정 +2. 컴포넌트 삭제 +3. 저장 후 로드 +4. DB 확인: overrides에서도 제거되었는지 +``` + +### 테스트 4: 모드별 컴포넌트 숨김 +``` +1. "전화 걸기" 버튼 추가 +2. "표시" 탭 → 태블릿 모드 체크 해제 +3. 태블릿 가로: 버튼 안 보임 +4. 모바일 세로: 버튼 보임 +``` + +### 테스트 5: 속성 패널 UI +``` +1. 컴포넌트 선택 +2. "표시" 탭 클릭 +3. 4개 체크박스 확인 (모두 체크됨) +4. 체크 해제 시 "(숨김)" 표시 확인 +5. 저장 후 로드 → 체크 상태 유지 +``` + +--- + +## 🔍 기술적 고려사항 + +### 1. 데이터 일관성 +``` +문제: 컴포넌트 삭제 시 오버라이드 잔여물 + +해결: +- cleanupOverridesAfterDelete() 함수 +- containers.root.children 정리 +- components 오버라이드 정리 +- 빈 오버라이드 자동 삭제 +``` + +### 2. 병합 우선순위 +``` +우선순위 (높음 → 낮음): +1. tempLayout (고정 전 미리보기) +2. overrides[currentMode].containers.root +3. overrides[currentMode].components[id] +4. layout.root (기본값) +5. layout.components[id] (기본값) +``` + +### 3. 성능 최적화 +```typescript +// useMemo로 병합 결과 캐싱 +const effectiveRoot = useMemo(() => getMergedRoot(), [tempLayout, overrides, currentMode]); +const mergedComponent = useMemo(() => getMergedComponent(baseComponent), [baseComponent, overrides, currentMode]); +``` + +### 4. 타입 안전성 +```typescript +// visibility 키는 ViewportPreset에서만 허용 +visibility?: { + [K in ViewportPreset]?: boolean; +}; + +// 컴파일 타임에 오타 방지 +visibility.tablet_landspace = false; // ❌ 오타 감지! +visibility.tablet_landscape = false; // ✅ 정상 +``` + +--- + +## 📊 영향 받는 파일 + +### 코드 파일 +``` +✅ frontend/components/pop/designer/types/pop-layout.ts + - PopComponentType 확장 (pop-break) + - PopComponentDefinitionV4.visibility 추가 + - cleanupOverridesAfterDelete() 추가 + +✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx + - isComponentVisible() 추가 + - getMergedComponent() 추가 + - pop-break 렌더링 추가 + - ContainerRenderer props 확장 + +✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx + - "표시" 탭 추가 + - VisibilityForm 컴포넌트 추가 + - COMPONENT_TYPE_LABELS 업데이트 + +✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx + - "줄바꿈" 컴포넌트 추가 +``` + +### 문서 파일 +``` +✅ popdocs/CHANGELOG.md + - Phase 3 완료 기록 + +✅ popdocs/PLAN.md + - Phase 3 체크 완료 + - Phase 4 계획 추가 + +✅ popdocs/decisions/002-phase3-visibility-break.md (이 문서) + - 설계 결정 및 구현 상세 +``` + +--- + +## 🚀 다음 단계 + +### Phase 4: 실제 컴포넌트 구현 +``` +우선순위: +1. pop-field (입력/표시 필드) +2. pop-button (액션 버튼) +3. pop-list (데이터 리스트) +4. pop-indicator (KPI 표시) +5. pop-scanner (바코드/QR) +6. pop-numpad (숫자 입력) +``` + +### 추가 개선 사항 +``` +1. 컴포넌트 오버라이드 UI + - 리스트 컬럼 수 조정 + - 버튼 스타일 변경 + - 필드 표시 형식 변경 + +2. "모든 모드에 적용" 기능 + - 한 번에 모든 모드 체크/해제 + +3. 오버라이드 비교 뷰 + - 기본값 vs 오버라이드 차이 표시 +``` + +--- + +## 📝 결론 + +Phase 3를 통해 다음을 달성: +1. ✅ 모드별 컴포넌트 표시/숨김 제어 +2. ✅ 강제 줄바꿈 컴포넌트 (Flexbox 한계 극복) +3. ✅ 컴포넌트 오버라이드 병합 (확장성 확보) +4. ✅ 데이터 일관성 유지 (삭제 시 정리) + +이제 v4 레이아웃 시스템의 핵심 기능이 완성되었으며, 실제 컴포넌트 구현 단계로 진행할 수 있습니다.