feat(pop): - 모드별 컴포넌트 표시/숨김 및 줄바꿈 기능 추가

visibility 속성으로 모드별 컴포넌트 표시/숨김 제어
pop-break 컴포넌트로 Flexbox 강제 줄바꿈 지원 (flex-basis: 100%)
컴포넌트 오버라이드 병합 로직 추가 (모드별 설정 변경 가능)
삭제 시 오버라이드 자동 정리 로직 구현
속성 패널에 "표시" 탭 추가 (체크박스 UI)
팔레트에 "줄바꿈" 컴포넌트 추가
popdocs 문서 정리 (PHASE3_SUMMARY, decisions/002, 기존 문서 업데이트)
This commit is contained in:
SeongHyun Kim 2026-02-04 18:23:59 +09:00
parent 760e545444
commit 5f23c13490
13 changed files with 2268 additions and 104 deletions

View File

@ -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<PopSizeConstraintV4>) => 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<ViewportPreset>(DEFAULT_PRESET);
// 커스텀 뷰포트 크기 (슬라이더)
const [customWidth, setCustomWidth] = useState(1024);
const [customHeight, setCustomHeight] = useState(768);
@ -85,7 +92,7 @@ export function PopCanvasV4({
const dropRef = useRef<HTMLDivElement>(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 (
<div className="relative flex h-full flex-col bg-gray-100">
{/* 툴바 */}
@ -185,16 +206,19 @@ export function PopCanvasV4({
<span className="text-xs text-muted-foreground mr-2">:</span>
{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 (
<Button
key={preset.id}
variant={isActive ? "default" : "outline"}
variant={isActive ? "default" : isEdited ? "secondary" : "outline"}
size="sm"
className={cn(
"h-8 gap-1 text-xs",
isDefault && !isActive && "border-primary/50"
isDefault && !isActive && "border-primary/50",
isEdited && !isActive && "border-yellow-500 bg-yellow-50 hover:bg-yellow-100"
)}
onClick={() => handleViewportChange(preset.id)}
title={preset.label}
@ -204,11 +228,42 @@ export function PopCanvasV4({
{isDefault && (
<span className="text-[10px] text-muted-foreground ml-1">()</span>
)}
{isEdited && (
<span className="text-[10px] text-yellow-700 ml-1 font-medium">()</span>
)}
</Button>
);
})}
</div>
{/* 고정 버튼 (기본 모드가 아닐 때 표시) */}
{currentMode !== DEFAULT_PRESET && onLockLayout && (
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
onClick={onLockLayout}
title="현재 배치를 이 모드 전용으로 고정합니다"
>
<Lock className="h-3 w-3" />
<span></span>
</Button>
)}
{/* 오버라이드 초기화 버튼 (편집된 모드에만 표시) */}
{hasOverride(currentMode) && onResetOverride && (
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs text-yellow-700 border-yellow-500 hover:bg-yellow-50"
onClick={() => onResetOverride(currentMode)}
title="이 모드의 편집 내용을 삭제하고 기본 규칙으로 되돌립니다"
>
<RotateCcw className="h-3 w-3" />
<span> </span>
</Button>
)}
{/* 줌 컨트롤 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
@ -291,6 +346,8 @@ export function PopCanvasV4({
<PopFlexRenderer
layout={layout}
viewportWidth={viewportWidth}
currentMode={currentMode}
tempLayout={tempLayout}
isDesignMode={true}
selectedComponentId={selectedComponentId}
onComponentClick={onSelectComponent}

View File

@ -179,6 +179,15 @@ export default function PopDesigner({
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
// ========================================
// v4용 뷰포트 모드 상태
// ========================================
type ViewportMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
const [currentViewportMode, setCurrentViewportMode] = useState<ViewportMode>("tablet_landscape");
// v4: 임시 레이아웃 (고정 전 배치) - 다른 모드에서만 사용
const [tempLayout, setTempLayout] = useState<PopContainerV4 | null>(null);
// ========================================
// 선택 상태
// ========================================
@ -354,6 +363,8 @@ export default function PopDesigner({
const handleUpdateContainerV4 = useCallback(
(containerId: string, updates: Partial<PopContainerV4>) => {
if (currentViewportMode === "tablet_landscape") {
// 기본 모드 (태블릿 가로) → root 직접 수정 ✅
const newLayout = {
...layoutV4,
root: updateContainerV4(layoutV4.root, containerId, updates),
@ -361,8 +372,14 @@ export default function PopDesigner({
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,6 +507,8 @@ export default function PopDesigner({
};
};
if (currentViewportMode === "tablet_landscape") {
// 기본 모드 → root 직접 수정 ✅
const newLayout = {
...layoutV4,
root: reorderInContainer(layoutV4.root),
@ -433,9 +516,16 @@ export default function PopDesigner({
setLayoutV4(newLayout);
saveToHistoryV4(newLayout);
setHasChanges(true);
console.log("[V4] 컴포넌트 순서 변경", { containerId, fromIndex, toIndex });
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}
/>
)}
</ResizablePanel>
@ -683,6 +778,7 @@ export default function PopDesigner({
<ComponentEditorPanelV4
component={selectedComponentV4}
container={selectedContainer}
currentViewportMode={currentViewportMode}
onUpdateComponent={
selectedComponentId
? (updates) => handleUpdateComponentV4(selectedComponentId, updates)

View File

@ -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<PopComponentDefinitionV4>) => void;
/** 컨테이너 업데이트 */
@ -57,6 +60,8 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
@ -71,6 +76,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
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 (
<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>
{isNonDefaultMode && (
<p className="text-xs text-amber-600 mt-1">
'고정'
</p>
)}
</div>
<div className="flex-1 overflow-auto p-4">
<ContainerSettingsForm
container={container}
currentViewportMode={currentViewportMode}
onUpdate={onUpdateContainer}
/>
</div>
@ -129,6 +143,10 @@ export function ComponentEditorPanelV4({
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="visibility" className="gap-1 text-xs">
<Eye className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data" className="gap-1 text-xs">
<Database className="h-3 w-3" />
@ -151,6 +169,14 @@ export function ComponentEditorPanelV4({
/>
</TabsContent>
{/* 모드별 표시 탭 */}
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
<VisibilityForm
component={component!}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 데이터 바인딩 탭 */}
<TabsContent value="data" className="flex-1 overflow-auto p-4">
<DataBindingPlaceholder />
@ -359,25 +385,6 @@ function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) {
</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>
);
}
@ -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<PopContainerV4>) => void;
}
function ContainerSettingsForm({
container,
currentViewportMode = "tablet_landscape",
onUpdate,
}: ContainerSettingsFormProps) {
const isNonDefaultMode = currentViewportMode !== "tablet_landscape";
return (
<div className="space-y-6">
{/* 방향 */}
@ -434,6 +445,7 @@ function ContainerSettingsForm({
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ direction: "horizontal" })}
disabled={isNonDefaultMode}
>
</Button>
@ -442,10 +454,16 @@ function ContainerSettingsForm({
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ direction: "vertical" })}
disabled={isNonDefaultMode}
>
</Button>
</div>
{isNonDefaultMode && (
<p className="text-[10px] text-amber-600">
'고정'
</p>
)}
</div>
{/* 줄바꿈 */}
@ -457,6 +475,7 @@ function ContainerSettingsForm({
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ wrap: true })}
disabled={isNonDefaultMode}
>
</Button>
@ -465,6 +484,7 @@ function ContainerSettingsForm({
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ wrap: false })}
disabled={isNonDefaultMode}
>
</Button>
@ -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}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
@ -496,6 +517,7 @@ function ContainerSettingsForm({
onChange={(e) =>
onUpdate?.({ padding: Number(e.target.value) || undefined })
}
disabled={isNonDefaultMode}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
@ -507,6 +529,7 @@ function ContainerSettingsForm({
<Select
value={container.alignItems}
onValueChange={(value) => onUpdate?.({ alignItems: value as any })}
disabled={isNonDefaultMode}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@ -525,6 +548,7 @@ function ContainerSettingsForm({
<Select
value={container.justifyContent}
onValueChange={(value) => onUpdate?.({ justifyContent: value as any })}
disabled={isNonDefaultMode}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@ -581,6 +605,94 @@ function ComponentSettingsForm({
);
}
// ========================================
// 모드별 표시/숨김 폼
// ========================================
interface VisibilityFormProps {
component: PopComponentDefinitionV4;
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => 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 (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<Eye className="h-3 w-3" />
</Label>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2 rounded-lg border p-3">
{modes.map(({ key, label }) => {
const isChecked = component.visibility?.[key] !== false;
return (
<div key={key} className="flex items-center gap-2">
<input
type="checkbox"
id={`visibility-${key}`}
checked={isChecked}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
className="h-4 w-4 rounded border-gray-300"
/>
<label
htmlFor={`visibility-${key}`}
className="text-xs cursor-pointer flex-1"
>
{label}
</label>
{!isChecked && (
<span className="text-xs text-muted-foreground">()</span>
)}
</div>
);
})}
</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={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>
<p className="text-xs text-muted-foreground">
: 500 500px
</p>
</div>
</div>
);
}
// ========================================
// 데이터 바인딩 플레이스홀더
// ========================================

View File

@ -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%)",
},
];
// ========================================

View File

@ -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<PopComponentType, string> = {
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
@ -74,6 +79,8 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
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 (
<div
className={cn("h-full w-full", className)}
@ -104,12 +161,14 @@ export function PopFlexRenderer({
}
}}
>
{/* 루트 컨테이너 렌더링 */}
{/* 루트 컨테이너 렌더링 (병합된 레이아웃 사용) */}
<ContainerRenderer
container={root}
container={effectiveRoot}
components={components}
viewportWidth={viewportWidth}
settings={settings}
currentMode={currentMode}
overrides={overrides}
isDesignMode={isDesignMode}
selectedComponentId={selectedComponentId}
onComponentClick={onComponentClick}
@ -130,6 +189,8 @@ interface ContainerRendererProps {
components: Record<string, PopComponentDefinitionV4>;
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 (
<div
key={componentId}
className={cn(
"w-full",
isDesignMode
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
: "h-0"
)}
style={{ flexBasis: "100%" }}
onClick={() => onComponentClick?.(componentId)}
>
{isDesignMode && (
<span className="text-xs text-gray-400"></span>
)}
</div>
);
}
return (
<DraggableComponentWrapper
key={componentId}
@ -241,7 +357,7 @@ function ContainerRenderer({
>
<ComponentRendererV4
componentId={componentId}
component={compDef}
component={mergedComponent}
settings={settings}
viewportWidth={viewportWidth}
isDesignMode={isDesignMode}

View File

@ -13,6 +13,7 @@
* v4 ()
* - 소스: 4모드 X
* - 기반: 컴포넌트가
* - 오버라이드: 특정 (Phase 2)
*/
export interface PopLayoutDataV4 {
version: "pop-4.0";
@ -31,6 +32,28 @@ export interface PopLayoutDataV4 {
// 메타데이터
metadata?: PopLayoutMetadata;
// 모드별 오버라이드 (Phase 2)
// - tablet_landscape가 기본이므로 오버라이드 없음
// - 나머지 3개 모드만 오버라이드 가능
overrides?: {
mobile_portrait?: PopModeOverride;
mobile_landscape?: PopModeOverride;
tablet_portrait?: PopModeOverride;
};
}
/**
*
* -
* - (tablet_landscape)
*/
export interface PopModeOverride {
// 컴포넌트별 오버라이드
components?: Record<string, Partial<PopComponentDefinitionV4>>;
// 컨테이너별 오버라이드
containers?: Record<string, Partial<PopContainerV4>>;
}
/**
@ -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,22 +328,78 @@ 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<keyof typeof 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;
}
/**
* ()
@ -610,7 +702,8 @@ export type PopComponentType =
| "pop-indicator" // 상태/수치 표시
| "pop-scanner" // 바코드/QR 입력
| "pop-numpad" // 숫자 입력 특화
| "pop-spacer"; // 빈 공간 (레이아웃 정렬용)
| "pop-spacer" // 빈 공간 (레이아웃 정렬용)
| "pop-break"; // 줄바꿈 (강제 줄바꿈, flex-basis: 100%)
// ========================================
// 데이터 흐름

View File

@ -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

518
popdocs/PHASE3_SUMMARY.md Normal file
View File

@ -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 (
<div
style={{ flexBasis: "100%" }} // 핵심: 100% 너비로 줄바꿈 강제
className={isDesignMode
? "h-4 border-2 border-dashed border-gray-300"
: "h-0"
}
>
{isDesignMode && <span>줄바꿈</span>}
</div>
);
}
```
---
### 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
<TabsList>
<TabsTrigger value="size">크기</TabsTrigger>
<TabsTrigger value="settings">설정</TabsTrigger>
<TabsTrigger value="visibility">
<Eye className="h-3 w-3" />
표시
</TabsTrigger>
<TabsTrigger value="data">데이터</TabsTrigger>
</TabsList>
```
#### 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 (
<div>
{modes.map(({ key, label }) => (
<input
type="checkbox"
checked={component.visibility?.[key] !== false}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
/>
))}
</div>
);
}
```
---
### 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 (실제 컴포넌트 구현)*

View File

@ -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: 숫자 입력 패드
---

View File

@ -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 완료)*

View File

@ -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 완료)*

View File

@ -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 완료)*

View File

@ -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<PopComponentType, PopSizeConstraintV4> = {
// ...
"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 (
<div
key={componentId}
className={cn(
"w-full",
isDesignMode
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
: "h-0"
)}
style={{ flexBasis: "100%" }} // 핵심: 100% 너비로 줄바꿈 강제
onClick={() => onComponentClick?.(componentId)}
>
{isDesignMode && (
<span className="text-xs text-gray-400">줄바꿈</span>
)}
</div>
);
}
```
**동작 방식**:
- `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
<TabsList>
<TabsTrigger value="size">크기</TabsTrigger>
<TabsTrigger value="settings">설정</TabsTrigger>
<TabsTrigger value="visibility">
<Eye className="h-3 w-3" />
표시
</TabsTrigger>
<TabsTrigger value="data">데이터</TabsTrigger>
</TabsList>
```
#### 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 (
<div className="space-y-4">
<Label>모드별 표시 설정</Label>
<p className="text-xs text-muted-foreground">
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
</p>
<div className="space-y-2 rounded-lg border p-3">
{modes.map(({ key, label }) => {
const isChecked = component.visibility?.[key] !== false;
return (
<div key={key} className="flex items-center gap-2">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
/>
<label>{label}</label>
{!isChecked && <span>(숨김)</span>}
</div>
);
})}
</div>
{/* 기존: 반응형 숨김 (픽셀 기반) */}
<div className="space-y-3">
<Label>반응형 숨김 (픽셀 기반)</Label>
<Input
type="number"
value={component.hideBelow || ""}
onChange={(e) =>
onUpdate?.({
hideBelow: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder="없음"
/>
<p className="text-xs text-muted-foreground">
예: 500 입력 시 화면 너비가 500px 이하면 자동 숨김
</p>
</div>
</div>
);
}
```
**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 레이아웃 시스템의 핵심 기능이 완성되었으며, 실제 컴포넌트 구현 단계로 진행할 수 있습니다.