feat(pop-designer): 반응형 그리드 시스템 고도화

- 브레이크포인트 재설계: 실제 기기 CSS 뷰포트 기반 (479/767/1023px)
- 자동 줄바꿈 시스템: col > maxCol 컴포넌트 자동 재배치, 검토 필요 알림
- Gap 프리셋: 좁게/보통/넓게 3단계 간격 조절
- 셀 크기 강제 고정: gridTemplateRows + overflow-hidden
- 세로 자동 확장: 동적 캔버스 높이 계산 (최소 600px)
- 뷰어 모드 일관성: detectGridMode() 직접 사용
- 컴포넌트 ID 충돌 방지: 로드 시 idCounter 자동 설정
- popdocs 문서 정비: ADR 2건, 레거시 문서 archive 이동

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-06 15:30:57 +09:00
parent 726f6ac395
commit 40219fed08
25 changed files with 1784 additions and 588 deletions

View File

@ -2701,4 +2701,3 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons
res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message });
}
};

View File

@ -20,6 +20,9 @@ import {
GridMode,
isV5Layout,
createEmptyPopLayoutV5,
GAP_PRESETS,
GRID_BREAKPOINTS,
detectGridMode,
} from "@/components/pop/designer/types/pop-layout";
import PopRenderer from "@/components/pop/designer/renderers/PopRenderer";
import {
@ -27,15 +30,15 @@ import {
type DeviceType,
} from "@/hooks/useDeviceOrientation";
// 디바이스별 크기 (프리뷰 모드용)
const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width: number; height: number; label: string }>> = {
// 디바이스별 크기 (너비만, 높이는 콘텐츠 기반)
const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width: number; label: string }>> = {
mobile: {
landscape: { width: 667, height: 375, label: "모바일 가로" },
portrait: { width: 375, height: 667, label: "모바일 세로" },
landscape: { width: 600, label: "모바일 가로" },
portrait: { width: 375, label: "모바일 세로" },
},
tablet: {
landscape: { width: 1024, height: 768, label: "태블릿 가로" },
portrait: { width: 768, height: 1024, label: "태블릿 세로" },
landscape: { width: 1024, label: "태블릿 가로" },
portrait: { width: 820, label: "태블릿 세로" },
},
};
@ -69,7 +72,6 @@ function PopScreenViewPage() {
// 현재 모드 정보
const deviceType = mode.device;
const isLandscape = mode.isLandscape;
const currentModeKey = getModeKey(deviceType, isLandscape);
const { user } = useAuth();
@ -81,6 +83,13 @@ function PopScreenViewPage() {
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
// 모드 결정:
// - 프리뷰 모드: 수동 선택한 device/orientation 사용
// - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치)
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape)
: detectGridMode(viewportWidth);
useEffect(() => {
const updateViewportWidth = () => {
setViewportWidth(Math.min(window.innerWidth, 1366));
@ -261,25 +270,38 @@ function PopScreenViewPage() {
)}
<div
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-hidden border-8 border-gray-800" : "w-full h-full"}`}
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full"}`}
style={isPreviewMode ? {
width: currentDevice.width,
height: currentDevice.height,
maxHeight: "80vh",
flexShrink: 0,
} : undefined}
>
{/* v5 그리드 렌더러 */}
{hasComponents ? (
<div
className="mx-auto h-full"
className="mx-auto min-h-full"
style={{ maxWidth: 1366 }}
>
<PopRenderer
layout={layout}
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
currentMode={currentModeKey}
isDesignMode={false}
/>
{(() => {
// Gap 프리셋 계산
const currentGapPreset = layout.settings.gapPreset || "medium";
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
const breakpoint = GRID_BREAKPOINTS[currentModeKey];
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
return (
<PopRenderer
layout={layout}
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
currentMode={currentModeKey}
isDesignMode={false}
overrideGap={adjustedGap}
overridePadding={adjustedPadding}
/>
);
})()}
</div>
) : (
// 빈 화면

View File

@ -9,15 +9,24 @@ import {
PopComponentType,
PopGridPosition,
GridMode,
GapPreset,
GAP_PRESETS,
GRID_BREAKPOINTS,
DEFAULT_COMPONENT_GRID_SIZE,
} from "./types/pop-layout";
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react";
import { useDrag } from "react-dnd";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { toast } from "sonner";
import PopRenderer from "./renderers/PopRenderer";
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, isOutOfBounds } from "./utils/gridUtils";
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils";
import { DND_ITEM_TYPES } from "./constants";
/**
@ -65,13 +74,13 @@ interface DragItemMoveComponent {
}
// ========================================
// 프리셋 해상도 (4개 모드)
// 프리셋 해상도 (4개 모드) - 너비만 정의
// ========================================
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, height: 667, icon: Smartphone },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 667, height: 375, icon: Smartphone },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 768, height: 1024, icon: Tablet },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, height: 768, icon: Tablet },
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet },
] as const;
type ViewportPreset = GridMode;
@ -79,6 +88,10 @@ type ViewportPreset = GridMode;
// 기본 프리셋 (태블릿 가로)
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
// 캔버스 세로 자동 확장 설정
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 (px)
const CANVAS_EXTRA_ROWS = 3; // 여유 행 수
// ========================================
// Props
// ========================================
@ -98,6 +111,7 @@ interface PopCanvasProps {
onUnhideComponent?: (componentId: string) => void;
onLockLayout?: () => void;
onResetOverride?: (mode: GridMode) => void;
onChangeGapPreset?: (preset: GapPreset) => void;
}
// ========================================
@ -120,13 +134,13 @@ export default function PopCanvas({
onUnhideComponent,
onLockLayout,
onResetOverride,
onChangeGapPreset,
}: PopCanvasProps) {
// 줌 상태
const [canvasScale, setCanvasScale] = useState(0.8);
// 커스텀 뷰포트 크기
// 커스텀 뷰포트 너비
const [customWidth, setCustomWidth] = useState(1024);
const [customHeight, setCustomHeight] = useState(768);
// 그리드 가이드 표시 여부
const [showGridGuide, setShowGridGuide] = useState(true);
@ -142,12 +156,48 @@ export default function PopCanvas({
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
const breakpoint = GRID_BREAKPOINTS[currentMode];
// 그리드 라벨 계산
// Gap 프리셋 적용
const currentGapPreset = layout.settings.gapPreset || "medium";
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
// 숨김 컴포넌트 ID 목록
const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || [];
// 동적 캔버스 높이 계산 (컴포넌트 배치 기반)
const dynamicCanvasHeight = useMemo(() => {
const visibleComps = Object.values(layout.components).filter(
comp => !hiddenComponentIds.includes(comp.id)
);
if (visibleComps.length === 0) return MIN_CANVAS_HEIGHT;
// 최대 row + rowSpan 찾기
const maxRowEnd = visibleComps.reduce((max, comp) => {
const overridePos = layout.overrides?.[currentMode]?.positions?.[comp.id];
const pos = overridePos ? { ...comp.position, ...overridePos } : comp.position;
const rowEnd = pos.row + pos.rowSpan;
return Math.max(max, rowEnd);
}, 1);
// 높이 계산: (행 수 + 여유) * (행높이 + gap) + padding
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2;
return Math.max(MIN_CANVAS_HEIGHT, height);
}, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]);
// 그리드 라벨 계산 (동적 행 수)
const gridLabels = useMemo(() => {
const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1);
const rowLabels = Array.from({ length: 20 }, (_, i) => i + 1);
// 동적 행 수 계산
const rowCount = Math.ceil(dynamicCanvasHeight / (breakpoint.rowHeight + adjustedGap));
const rowLabels = Array.from({ length: rowCount }, (_, i) => i + 1);
return { columnLabels, rowLabels };
}, [breakpoint.columns]);
}, [breakpoint.columns, breakpoint.rowHeight, dynamicCanvasHeight, adjustedGap]);
// 줌 컨트롤
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
@ -159,7 +209,7 @@ export default function PopCanvas({
onModeChange(mode);
const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!;
setCustomWidth(presetData.width);
setCustomHeight(presetData.height);
// customHeight는 dynamicCanvasHeight로 자동 계산됨
};
// 패닝
@ -235,8 +285,8 @@ export default function PopCanvas({
customWidth,
breakpoint.columns,
breakpoint.rowHeight,
breakpoint.gap,
breakpoint.padding
adjustedGap,
adjustedPadding
);
const dragItem = item as DragItemComponent;
@ -289,8 +339,8 @@ export default function PopCanvas({
customWidth,
breakpoint.columns,
breakpoint.rowHeight,
breakpoint.gap,
breakpoint.padding
adjustedGap,
adjustedPadding
);
const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean };
@ -299,7 +349,7 @@ export default function PopCanvas({
const effectivePositions = getAllEffectivePositions(layout, currentMode);
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
// 초과 컴포넌트(OutOfBoundsPanel에서 드래그)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
const componentData = layout.components[dragItem.componentId];
@ -348,7 +398,7 @@ export default function PopCanvas({
canDrop: monitor.canDrop(),
}),
}),
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, customHeight]
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding]
);
drop(canvasRef);
@ -356,43 +406,36 @@ export default function PopCanvas({
// 빈 상태 체크
const isEmpty = Object.keys(layout.components).length === 0;
// 숨김 처리된 컴포넌트 목록
const hiddenComponentIds = useMemo(() => {
return layout.overrides?.[currentMode]?.hidden || [];
}, [layout.overrides, currentMode]);
// 숨김 처리된 컴포넌트 객체 목록
// 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨)
const hiddenComponents = useMemo(() => {
return hiddenComponentIds
.map(id => layout.components[id])
.filter(Boolean);
}, [hiddenComponentIds, layout.components]);
// 초과 컴포넌트 목록 (오른쪽 영역에 표시)
// 오버라이드가 있는 컴포넌트는 오버라이드 위치로 판단
// 숨김 처리된 컴포넌트는 제외
const outOfBoundsComponents = useMemo(() => {
return Object.values(layout.components).filter(comp => {
// 숨김 처리된 컴포넌트는 초과 목록에서 제외
if (hiddenComponentIds.includes(comp.id)) return false;
// 오버라이드 위치 확인
const overridePos = layout.overrides?.[currentMode]?.positions?.[comp.id];
const overridePosition = overridePos
? { ...comp.position, ...overridePos }
: null;
return isOutOfBounds(comp.position, currentMode, overridePosition);
// 표시되는 컴포넌트 목록 (숨김 제외)
const visibleComponents = useMemo(() => {
return Object.values(layout.components).filter(
comp => !hiddenComponentIds.includes(comp.id)
);
}, [layout.components, hiddenComponentIds]);
// 검토 필요 컴포넌트 목록
const reviewComponents = useMemo(() => {
return visibleComponents.filter(comp => {
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id];
return needsReview(currentMode, hasOverride);
});
}, [layout.components, layout.overrides, currentMode, hiddenComponentIds]);
}, [visibleComponents, layout.overrides, currentMode]);
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
// 12칸 모드가 아닐 때만 패널 표시
// 초과 컴포넌트: 있을 때만 표시
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
const showOutOfBoundsPanel = currentMode !== "tablet_landscape" && outOfBoundsComponents.length > 0;
const hasGridComponents = Object.keys(layout.components).length > 0;
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
const showRightPanel = showOutOfBoundsPanel || showHiddenPanel;
const showRightPanel = showReviewPanel || showHiddenPanel;
return (
<div className="flex h-full flex-col bg-gray-50">
@ -457,7 +500,29 @@ export default function PopCanvas({
{/* 해상도 표시 */}
<div className="text-xs text-muted-foreground">
{customWidth} × {customHeight}
{customWidth} × {Math.round(dynamicCanvasHeight)}
</div>
<div className="h-4 w-px bg-gray-300" />
{/* Gap 프리셋 선택 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">:</span>
<Select
value={currentGapPreset}
onValueChange={(value) => onChangeGapPreset?.(value as GapPreset)}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
<SelectItem key={preset} value={preset} className="text-xs">
{GAP_PRESETS[preset].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" />
@ -528,7 +593,7 @@ export default function PopCanvas({
width: showRightPanel
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
: `${customWidth + 32}px`,
minHeight: `${customHeight + 32}px`,
minHeight: `${dynamicCanvasHeight + 32}px`,
transform: `scale(${canvasScale})`,
}}
>
@ -541,8 +606,8 @@ export default function PopCanvas({
<div
className="flex absolute top-0 left-8"
style={{
gap: `${breakpoint.gap}px`,
paddingLeft: `${breakpoint.padding}px`,
gap: `${adjustedGap}px`,
paddingLeft: `${adjustedPadding}px`,
}}
>
{gridLabels.columnLabels.map((num) => (
@ -550,7 +615,7 @@ export default function PopCanvas({
key={`col-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: `calc((${customWidth}px - ${breakpoint.padding * 2}px - ${breakpoint.gap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
width: `calc((${customWidth}px - ${adjustedPadding * 2}px - ${adjustedGap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
height: "24px",
}}
>
@ -563,8 +628,8 @@ export default function PopCanvas({
<div
className="flex flex-col absolute top-8 left-0"
style={{
gap: `${breakpoint.gap}px`,
paddingTop: `${breakpoint.padding}px`,
gap: `${adjustedGap}px`,
paddingTop: `${adjustedPadding}px`,
}}
>
{gridLabels.rowLabels.map((num) => (
@ -592,7 +657,7 @@ export default function PopCanvas({
)}
style={{
width: `${customWidth}px`,
minHeight: `${customHeight}px`,
minHeight: `${dynamicCanvasHeight}px`,
marginLeft: "32px",
marginTop: "32px",
}}
@ -623,6 +688,8 @@ export default function PopCanvas({
onComponentMove={onMoveComponent}
onComponentResize={onResizeComponent}
onComponentResizeEnd={onResizeEnd}
overrideGap={adjustedGap}
overridePadding={adjustedPadding}
/>
)}
</div>
@ -634,13 +701,12 @@ export default function PopCanvas({
className="flex flex-col gap-3"
style={{ marginTop: "32px" }}
>
{/* 초과 컴포넌트 패널 */}
{showOutOfBoundsPanel && (
<OutOfBoundsPanel
components={outOfBoundsComponents}
{/* 검토 필요 패널 */}
{showReviewPanel && (
<ReviewPanel
components={reviewComponents}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
onHideComponent={onHideComponent}
/>
)}
@ -672,61 +738,96 @@ export default function PopCanvas({
}
// ========================================
// 초과 컴포넌트 영역 (오른쪽 패널)
// 검토 필요 영역 (오른쪽 패널)
// ========================================
interface OutOfBoundsPanelProps {
interface ReviewPanelProps {
components: PopComponentDefinitionV5[];
selectedComponentId: string | null;
onSelectComponent: (id: string | null) => void;
onHideComponent?: (componentId: string) => void;
}
function OutOfBoundsPanel({
function ReviewPanel({
components,
selectedComponentId,
onSelectComponent,
onHideComponent,
}: OutOfBoundsPanelProps) {
}: ReviewPanelProps) {
return (
<div
className="flex flex-col rounded-lg border-2 border-dashed border-orange-300 bg-orange-50/50"
className="flex flex-col rounded-lg border-2 border-dashed border-blue-300 bg-blue-50/50"
style={{
width: "200px",
maxHeight: "300px",
}}
>
{/* 헤더 */}
<div className="flex items-center gap-2 border-b border-orange-200 bg-orange-100/50 px-3 py-2 rounded-t-lg">
<AlertTriangle className="h-4 w-4 text-orange-600" />
<span className="text-xs font-semibold text-orange-700">
({components.length})
<div className="flex items-center gap-2 border-b border-blue-200 bg-blue-100/50 px-3 py-2 rounded-t-lg">
<AlertTriangle className="h-4 w-4 text-blue-600" />
<span className="text-xs font-semibold text-blue-700">
({components.length})
</span>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-auto p-2 space-y-2">
{components.map((comp) => (
<OutOfBoundsItem
<ReviewItem
key={comp.id}
component={comp}
isSelected={selectedComponentId === comp.id}
onSelect={() => onSelectComponent(comp.id)}
onHide={() => onHideComponent?.(comp.id)}
/>
))}
</div>
{/* 안내 문구 */}
<div className="border-t border-orange-200 px-3 py-2 bg-orange-50/80 rounded-b-lg">
<p className="text-[10px] text-orange-600 leading-tight">
/
<div className="border-t border-blue-200 px-3 py-2 bg-blue-50/80 rounded-b-lg">
<p className="text-[10px] text-blue-600 leading-tight">
.
</p>
</div>
</div>
);
}
// ========================================
// 검토 필요 아이템 (ReviewPanel 내부)
// ========================================
interface ReviewItemProps {
component: PopComponentDefinitionV5;
isSelected: boolean;
onSelect: () => void;
}
function ReviewItem({
component,
isSelected,
onSelect,
}: ReviewItemProps) {
return (
<div
className={cn(
"flex flex-col gap-1 rounded-md border-2 p-2 cursor-pointer transition-all",
isSelected
? "border-blue-500 bg-blue-100 shadow-sm"
: "border-blue-200 bg-white hover:border-blue-400 hover:bg-blue-50"
)}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
>
<span className="text-xs font-medium text-blue-800 line-clamp-1">
{component.label || component.id}
</span>
<span className="text-[10px] text-blue-600 bg-blue-50 rounded px-1.5 py-0.5 self-start">
</span>
</div>
);
}
// ========================================
// 숨김 컴포넌트 영역 (오른쪽 패널)
// ========================================
@ -812,68 +913,6 @@ function HiddenPanel({
);
}
// ========================================
// 초과 컴포넌트 아이템 (드래그 가능)
// ========================================
interface OutOfBoundsItemProps {
component: PopComponentDefinitionV5;
isSelected: boolean;
onSelect: () => void;
onHide: () => void;
}
function OutOfBoundsItem({
component,
isSelected,
onSelect,
onHide,
}: OutOfBoundsItemProps) {
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_ITEM_TYPES.MOVE_COMPONENT,
item: {
componentId: component.id,
originalPosition: component.position,
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[component.id, component.position]
);
// 클릭 시 숨김 처리
const handleClick = () => {
onSelect();
onHide();
};
return (
<div
ref={drag}
className={cn(
"rounded-md border-2 bg-white p-2 cursor-move transition-all",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-300 hover:border-gray-400",
isDragging && "opacity-50"
)}
onClick={handleClick}
>
{/* 컴포넌트 이름 */}
<div className="text-xs font-medium text-gray-700 truncate">
{component.label || component.type}
</div>
{/* 원본 위치 정보 */}
<div className="text-[10px] text-gray-500 mt-1">
: {component.position.col}, {component.position.row}
({component.position.colSpan}×{component.position.rowSpan})
</div>
</div>
);
}
// ========================================
// 숨김 컴포넌트 아이템 (드래그 가능)

View File

@ -21,6 +21,7 @@ import {
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GapPreset,
createEmptyPopLayoutV5,
isV5Layout,
addComponentToV5Layout,
@ -132,10 +133,27 @@ export default function PopDesigner({
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
// v5 레이아웃 로드
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
if (!loadedLayout.settings.gapPreset) {
loadedLayout.settings.gapPreset = "medium";
}
setLayout(loadedLayout);
setHistory([loadedLayout]);
setHistoryIndex(0);
console.log(`POP 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`);
// 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지)
const existingIds = Object.keys(loadedLayout.components);
const maxId = existingIds.reduce((max, id) => {
const match = id.match(/comp_(\d+)/);
if (match) {
const num = parseInt(match[1], 10);
return num > max ? num : max;
}
return max;
}, 0);
setIdCounter(maxId + 1);
console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`);
} else {
// 새 화면 또는 빈 레이아웃
const emptyLayout = createEmptyPopLayoutV5();
@ -336,6 +354,23 @@ export default function PopDesigner({
[layout, saveToHistory]
);
// ========================================
// Gap 프리셋 관리
// ========================================
const handleChangeGapPreset = useCallback((preset: GapPreset) => {
const newLayout = {
...layout,
settings: {
...layout.settings,
gapPreset: preset,
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
}, [layout, saveToHistory]);
// ========================================
// 모드별 오버라이드 관리
// ========================================
@ -598,6 +633,7 @@ export default function PopDesigner({
onUnhideComponent={handleUnhideComponent}
onLockLayout={handleLockLayout}
onResetOverride={handleResetOverride}
onChangeGapPreset={handleChangeGapPreset}
/>
</ResizablePanel>

View File

@ -66,7 +66,7 @@ export default function ComponentEditorPanel({
// 선택된 컴포넌트 없음
if (!component) {
return (
<div className={cn("flex h-full flex-col", className)}>
<div className={cn("flex h-full flex-col bg-white", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"></h3>
</div>

View File

@ -16,7 +16,6 @@ import {
} from "../types/pop-layout";
import {
convertAndResolvePositions,
isOutOfBounds,
isOverlapping,
getAllEffectivePositions,
} from "../utils/gridUtils";
@ -48,6 +47,10 @@ interface PopRendererProps {
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
onComponentResizeEnd?: (componentId: string) => void;
/** Gap 오버라이드 (Gap 프리셋 적용된 값) */
overrideGap?: number;
/** Padding 오버라이드 (Gap 프리셋 적용된 값) */
overridePadding?: number;
/** 추가 className */
className?: string;
}
@ -76,6 +79,8 @@ export default function PopRenderer({
onComponentMove,
onComponentResize,
onComponentResizeEnd,
overrideGap,
overridePadding,
className,
}: PopRendererProps) {
const { gridConfig, components, overrides } = layout;
@ -84,26 +89,45 @@ export default function PopRenderer({
const mode = currentMode || detectGridMode(viewportWidth);
const breakpoint = GRID_BREAKPOINTS[mode];
// CSS Grid 스타일
// Gap/Padding: 오버라이드 우선, 없으면 기본값 사용
const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap;
const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding;
// 숨김 컴포넌트 ID 목록
const hiddenIds = overrides?.[mode]?.hidden || [];
// 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외)
const dynamicRowCount = useMemo(() => {
const visibleComps = Object.values(components).filter(
comp => !hiddenIds.includes(comp.id)
);
const maxRowEnd = visibleComps.reduce((max, comp) => {
const override = overrides?.[mode]?.positions?.[comp.id];
const pos = override ? { ...comp.position, ...override } : comp.position;
return Math.max(max, pos.row + pos.rowSpan);
}, 1);
return Math.max(10, maxRowEnd + 3);
}, [components, overrides, mode, hiddenIds]);
// CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준)
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`,
gridAutoRows: `${breakpoint.rowHeight}px`,
gap: `${breakpoint.gap}px`,
padding: `${breakpoint.padding}px`,
gap: `${finalGap}px`,
padding: `${finalPadding}px`,
minHeight: "100%",
backgroundColor: "#ffffff",
position: "relative",
}), [breakpoint]);
}), [breakpoint, finalGap, finalPadding, dynamicRowCount]);
// 그리드 가이드 셀 생성
// 그리드 가이드 셀 생성 (동적 행 수)
const gridCells = useMemo(() => {
if (!isDesignMode || !showGridGuide) return [];
const cells = [];
const rowCount = 20; // 충분한 행 수
for (let row = 1; row <= rowCount; row++) {
for (let row = 1; row <= dynamicRowCount; row++) {
for (let col = 1; col <= breakpoint.columns; col++) {
cells.push({
id: `cell-${col}-${row}`,
@ -113,7 +137,7 @@ export default function PopRenderer({
}
}
return cells;
}, [isDesignMode, showGridGuide, breakpoint.columns]);
}, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]);
// visibility 체크
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
@ -192,7 +216,7 @@ export default function PopRenderer({
))}
{/* 컴포넌트 렌더링 (z-index로 위에 표시) */}
{/* 디자인 모드에서는 초과 컴포넌트를 그리드에서 제외 (오른쪽 별도 영역에 표시) */}
{/* v5.1: 자동 줄바꿈으로 모든 컴포넌트가 그리드 안에 배치됨 */}
{Object.values(components).map((comp) => {
// visibility 체크
if (!isVisible(comp)) return null;
@ -200,40 +224,47 @@ export default function PopRenderer({
// 오버라이드 숨김 체크
if (isHiddenByOverride(comp)) return null;
// 오버라이드 위치 가져오기 (있으면)
const overridePos = overrides?.[mode]?.positions?.[comp.id];
const overridePosition = overridePos
? { ...comp.position, ...overridePos }
: null;
// 초과 컴포넌트 체크 (오버라이드 고려)
const outOfBounds = isOutOfBounds(comp.position, mode, overridePosition);
// 디자인 모드에서 초과 컴포넌트는 그리드에 렌더링하지 않음
// (PopCanvas의 OutOfBoundsPanel에서 별도로 렌더링)
if (isDesignMode && outOfBounds) return null;
const position = getEffectivePosition(comp);
const positionStyle = convertPosition(position);
const isSelected = selectedComponentId === comp.id;
// 디자인 모드에서는 드래그 가능한 컴포넌트, 뷰어 모드에서는 일반 컴포넌트
if (isDesignMode) {
return (
<DraggableComponent
key={comp.id}
component={comp}
position={position}
positionStyle={positionStyle}
isSelected={isSelected}
isDesignMode={isDesignMode}
breakpoint={breakpoint}
viewportWidth={viewportWidth}
allEffectivePositions={effectivePositionsMap}
effectiveGap={finalGap}
effectivePadding={finalPadding}
onComponentClick={onComponentClick}
onComponentMove={onComponentMove}
onComponentResize={onComponentResize}
onComponentResizeEnd={onComponentResizeEnd}
/>
);
}
// 뷰어 모드: 드래그 없는 일반 렌더링
return (
<DraggableComponent
<div
key={comp.id}
component={comp}
position={position}
positionStyle={positionStyle}
isSelected={isSelected}
isDesignMode={isDesignMode}
isOutOfBounds={false}
breakpoint={breakpoint}
viewportWidth={viewportWidth}
allEffectivePositions={effectivePositionsMap}
onComponentClick={onComponentClick}
onComponentMove={onComponentMove}
onComponentResize={onComponentResize}
onComponentResizeEnd={onComponentResizeEnd}
/>
className="relative rounded-lg border-2 border-gray-200 bg-white transition-all overflow-hidden z-10"
style={positionStyle}
>
<ComponentContent
component={comp}
effectivePosition={position}
isDesignMode={false}
isSelected={false}
/>
</div>
);
})}
</div>
@ -250,10 +281,11 @@ interface DraggableComponentProps {
positionStyle: React.CSSProperties;
isSelected: boolean;
isDesignMode: boolean;
isOutOfBounds: boolean;
breakpoint: GridBreakpoint;
viewportWidth: number;
allEffectivePositions: Map<string, PopGridPosition>;
effectiveGap: number;
effectivePadding: number;
onComponentClick?: (componentId: string) => void;
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
@ -266,10 +298,11 @@ function DraggableComponent({
positionStyle,
isSelected,
isDesignMode,
isOutOfBounds,
breakpoint,
viewportWidth,
allEffectivePositions,
effectiveGap,
effectivePadding,
onComponentClick,
onComponentMove,
onComponentResize,
@ -294,10 +327,7 @@ function DraggableComponent({
<div
ref={isDesignMode ? drag : null}
className={cn(
"relative rounded-lg border-2 transition-all overflow-visible z-10",
// 초과 컴포넌트 스타일 (디자인 모드에서만)
isDesignMode && isOutOfBounds && "opacity-40 bg-gray-400/30",
!isOutOfBounds && "bg-white",
"relative rounded-lg border-2 transition-all overflow-hidden z-10 bg-white",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
@ -309,28 +339,24 @@ function DraggableComponent({
e.stopPropagation();
onComponentClick?.(component.id);
}}
title={
isDesignMode && isOutOfBounds
? `이 컴포넌트는 ${breakpoint.label}에서 표시되지 않습니다`
: undefined
}
>
<ComponentContent
component={component}
effectivePosition={position}
isDesignMode={isDesignMode}
isSelected={isSelected}
isOutOfBounds={isOutOfBounds}
/>
{/* 리사이즈 핸들 (선택된 컴포넌트만, 초과 아닐 때만) */}
{isDesignMode && isSelected && !isOutOfBounds && onComponentResize && (
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
{isDesignMode && isSelected && onComponentResize && (
<ResizeHandles
component={component}
position={position}
breakpoint={breakpoint}
viewportWidth={viewportWidth}
allEffectivePositions={allEffectivePositions}
effectiveGap={effectiveGap}
effectivePadding={effectivePadding}
onResize={onComponentResize}
onResizeEnd={onComponentResizeEnd}
/>
@ -349,6 +375,8 @@ interface ResizeHandlesProps {
breakpoint: GridBreakpoint;
viewportWidth: number;
allEffectivePositions: Map<string, PopGridPosition>;
effectiveGap: number;
effectivePadding: number;
onResize: (componentId: string, newPosition: PopGridPosition) => void;
onResizeEnd?: (componentId: string) => void;
}
@ -359,6 +387,8 @@ function ResizeHandles({
breakpoint,
viewportWidth,
allEffectivePositions,
effectiveGap,
effectivePadding,
onResize,
onResizeEnd,
}: ResizeHandlesProps) {
@ -371,11 +401,11 @@ function ResizeHandles({
const startColSpan = position.colSpan;
const startRowSpan = position.rowSpan;
// 그리드 셀 크기 동적 계산
// 그리드 셀 크기 동적 계산 (Gap 프리셋 적용된 값 사용)
// 사용 가능한 너비 = 뷰포트 너비 - 양쪽 패딩 - gap*(칸수-1)
const availableWidth = viewportWidth - breakpoint.padding * 2 - breakpoint.gap * (breakpoint.columns - 1);
const cellWidth = availableWidth / breakpoint.columns + breakpoint.gap; // 셀 너비 + gap 단위
const cellHeight = breakpoint.rowHeight + breakpoint.gap;
const availableWidth = viewportWidth - effectivePadding * 2 - effectiveGap * (breakpoint.columns - 1);
const cellWidth = availableWidth / breakpoint.columns + effectiveGap; // 셀 너비 + gap 단위
const cellHeight = breakpoint.rowHeight + effectiveGap;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - startX;
@ -465,10 +495,9 @@ interface ComponentContentProps {
effectivePosition: PopGridPosition;
isDesignMode: boolean;
isSelected: boolean;
isOutOfBounds: boolean;
}
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, isOutOfBounds }: ComponentContentProps) {
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 디자인 모드: 플레이스홀더 표시
@ -488,21 +517,11 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
)}>
{component.label || typeLabel}
</span>
{/* 초과 표시 */}
{isOutOfBounds && (
<span className="ml-1 text-[9px] text-orange-600 font-semibold">
</span>
)}
</div>
{/* 내용 */}
<div className="flex flex-1 items-center justify-center p-2">
<span className={cn(
"text-xs",
isOutOfBounds ? "text-gray-500" : "text-gray-400"
)}>
<span className="text-xs text-gray-400">
{typeLabel}
</span>
</div>

View File

@ -119,11 +119,12 @@ export interface GridBreakpoint {
/**
*
* (768px, 1024px) +
*/
export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
// 4~6인치 모바일 세로
// 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra)
mobile_portrait: {
maxWidth: 599,
maxWidth: 479,
columns: 4,
rowHeight: 40,
gap: 8,
@ -131,10 +132,10 @@ export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
label: "모바일 세로 (4칸)",
},
// 6~8인치 모바일 가로 / 작은 태블릿
// 스마트폰 가로 + 소형 태블릿
mobile_landscape: {
minWidth: 600,
maxWidth: 839,
minWidth: 480,
maxWidth: 767,
columns: 6,
rowHeight: 44,
gap: 8,
@ -142,9 +143,9 @@ export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
label: "모바일 가로 (6칸)",
},
// 8~10인치 태블릿 세로
// 태블릿 세로 (iPad Mini ~ iPad Pro)
tablet_portrait: {
minWidth: 840,
minWidth: 768,
maxWidth: 1023,
columns: 8,
rowHeight: 48,
@ -153,7 +154,7 @@ export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
label: "태블릿 세로 (8칸)",
},
// 10~14인치 태블릿 가로 (기본)
// 태블릿 가로 + 데스크톱 (기본)
tablet_landscape: {
minWidth: 1024,
columns: 12,
@ -171,10 +172,11 @@ export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
/**
*
* GRID_BREAKPOINTS와
*/
export function detectGridMode(viewportWidth: number): GridMode {
if (viewportWidth < 600) return "mobile_portrait";
if (viewportWidth < 840) return "mobile_landscape";
if (viewportWidth < 480) return "mobile_portrait";
if (viewportWidth < 768) return "mobile_landscape";
if (viewportWidth < 1024) return "tablet_portrait";
return "tablet_landscape";
}
@ -257,6 +259,28 @@ export interface PopComponentDefinitionV5 {
config?: PopComponentConfig;
}
/**
* Gap
*/
export type GapPreset = "narrow" | "medium" | "wide";
/**
* Gap
*/
export interface GapPresetConfig {
multiplier: number;
label: string;
}
/**
* Gap
*/
export const GAP_PRESETS: Record<GapPreset, GapPresetConfig> = {
narrow: { multiplier: 0.5, label: "좁게" },
medium: { multiplier: 1.0, label: "보통" },
wide: { multiplier: 1.5, label: "넓게" },
};
/**
* v5
*/
@ -266,6 +290,9 @@ export interface PopGlobalSettingsV5 {
// 모드
mode: "normal" | "industrial";
// Gap 프리셋
gapPreset: GapPreset; // 기본 "medium"
}
/**
@ -298,6 +325,7 @@ export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
settings: {
touchTargetMin: 48,
mode: "normal",
gapPreset: "medium",
},
});

View File

@ -2,10 +2,37 @@ import {
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
GridBreakpoint,
GapPreset,
GAP_PRESETS,
PopLayoutDataV5,
PopComponentDefinitionV5,
} from "../types/pop-layout";
// ========================================
// Gap/Padding 조정
// ========================================
/**
* Gap breakpoint의 gap/padding
*
* @param base breakpoint
* @param preset Gap ("narrow" | "medium" | "wide")
* @returns breakpoint (gap, padding )
*/
export function getAdjustedBreakpoint(
base: GridBreakpoint,
preset: GapPreset
): GridBreakpoint {
const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0;
return {
...base,
gap: Math.round(base.gap * multiplier),
padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px
};
}
// ========================================
// 그리드 위치 변환
// ========================================
@ -49,37 +76,106 @@ export function convertPositionToMode(
/**
*
*
* v5.1 :
* - col > targetColumns인
* - 방지: 모든
*/
export function convertAndResolvePositions(
components: Array<{ id: string; position: PopGridPosition }>,
targetMode: GridMode
): Array<{ id: string; position: PopGridPosition }> {
// 엣지 케이스: 빈 배열
if (components.length === 0) {
return [];
}
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
// 1단계: 각 컴포넌트를 비율로 변환
// 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존)
const converted = components.map(comp => ({
id: comp.id,
position: convertPositionToMode(comp.position, targetMode),
originalCol: comp.position.col, // 원본 col 보존
}));
// 2단계: 겹침 해결 (아래로 밀기)
return resolveOverlaps(converted, targetColumns);
// 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리
const normalComponents = converted.filter(c => c.originalCol <= targetColumns);
const overflowComponents = converted.filter(c => c.originalCol > targetColumns);
// 3단계: 정상 컴포넌트의 최대 row 계산
const maxRow = normalComponents.length > 0
? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1))
: 0;
// 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치
let currentRow = maxRow + 1;
const wrappedComponents = overflowComponents.map(comp => {
const wrappedPosition: PopGridPosition = {
col: 1, // 왼쪽 끝부터 시작
row: currentRow,
colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한
rowSpan: comp.position.rowSpan,
};
currentRow += comp.position.rowSpan; // 다음 행으로 이동
return {
id: comp.id,
position: wrappedPosition,
};
});
// 5단계: 정상 + 줄바꿈 컴포넌트 병합
const adjusted = [
...normalComponents.map(c => ({ id: c.id, position: c.position })),
...wrappedComponents,
];
// 6단계: 겹침 해결 (아래로 밀기)
return resolveOverlaps(adjusted, targetColumns);
}
// ========================================
// 초과 컴포넌트 감지
// 검토 필요 판별
// ========================================
/**
*
* "검토 필요"
*
* :
* 1.
* 2.
* v5.1 :
* - 12 ( )
* - ( )
*
* @param originalPosition (12 )
* @param currentMode
* @param overridePosition ()
* @param hasOverride
* @returns true = , false =
*/
export function needsReview(
currentMode: GridMode,
hasOverride: boolean
): boolean {
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
// 12칸 모드는 기본 모드이므로 검토 불필요
if (targetColumns === 12) {
return false;
}
// 오버라이드가 있으면 이미 편집함 → 검토 완료
if (hasOverride) {
return false;
}
// 오버라이드 없으면 → 검토 필요
return true;
}
/**
* @deprecated v5.1 needsReview()
*
* isOutOfBounds는 "화면 밖" ,
* v5.1 .
* needsReview() "검토 필요" .
*/
export function isOutOfBounds(
originalPosition: PopGridPosition,
@ -95,11 +191,10 @@ export function isOutOfBounds(
// 오버라이드가 있으면 오버라이드 위치로 판단
if (overridePosition) {
// 오버라이드 시작 열이 범위 내면 "초과 아님"
return overridePosition.col > targetColumns;
}
// 오버라이드 없으면 원본 시작 열이 현재 모드 칸 수를 초과하면 "화면 밖"
// 오버라이드 없으면 원본 col로 판단
return originalPosition.col > targetColumns;
}
@ -421,7 +516,10 @@ export function getEffectiveComponentPosition(
/**
* .
* .
* .
*
* v5.1: 자동
* "화면 밖" .
*/
export function getAllEffectivePositions(
layout: PopLayoutDataV5,
@ -453,16 +551,10 @@ export function getAllEffectivePositions(
autoResolvedPositions
);
// v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음
// 따라서 추가 필터링 불필요
if (position) {
// 화면 밖 컴포넌트도 제외 (오버라이드 위치 고려)
const overridePos = layout.overrides?.[mode]?.positions?.[componentId];
const overridePosition = overridePos
? { ...layout.components[componentId].position, ...overridePos }
: null;
if (!isOutOfBounds(layout.components[componentId].position, mode, overridePosition)) {
result.set(componentId, position);
}
result.set(componentId, position);
}
});

View File

@ -17,11 +17,14 @@ export interface ResponsiveMode {
// ========================================
// 브레이크포인트 (화면 너비 기준)
// GRID_BREAKPOINTS와 일치해야 함!
// ========================================
const BREAKPOINTS = {
// 모바일: 0 ~ 767px
// 태블릿: 768px 이상
TABLET_MIN: 768,
// mobile_portrait: ~479px (4칸)
// mobile_landscape: 480~767px (6칸)
// tablet_portrait: 768~1023px (8칸)
// tablet_landscape: 1024px~ (12칸)
TABLET_MIN: 768, // 768px 이상이면 tablet
};
/**

View File

@ -1,6 +1,6 @@
# POP 화면 시스템 아키텍처
**최종 업데이트: 2026-02-05 (v5 그리드 시스템)**
**최종 업데이트: 2026-02-06 (v5.2 브레이크포인트 재설계 + 세로 자동 확장)**
POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다.
@ -79,14 +79,19 @@ handleUndo() / handleRedo() // 히스토리
// DnD 설정
const DND_ITEM_TYPES = { COMPONENT: "component" };
// 뷰포트 프리셋 (4개 모드)
// 뷰포트 프리셋 (4개 모드) - height 제거됨 (세로 무한 스크롤)
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", width: 375, columns: 4 },
{ id: "mobile_landscape", width: 667, columns: 6 },
{ id: "tablet_portrait", width: 768, columns: 8 },
{ id: "mobile_landscape", width: 600, columns: 6 },
{ id: "tablet_portrait", width: 834, columns: 8 },
{ id: "tablet_landscape", width: 1024, columns: 12 },
];
// 세로 자동 확장
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이
const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수
const dynamicCanvasHeight = useMemo(() => { ... }, []);
// 기능
- useDrop(): 팔레트에서 컴포넌트 드롭
- handleWheel(): 줌 (30%~150%)
@ -149,14 +154,22 @@ const convertPosition = (pos: PopGridPosition, targetMode: GridMode) => {
// 그리드 모드
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
// 브레이크포인트 설정
// 브레이크포인트 설정 (2026-02-06 재설계)
const GRID_BREAKPOINTS = {
mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 },
mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 },
tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 },
tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 },
mobile_portrait: { columns: 4, maxWidth: 479, gap: 8, padding: 12 },
mobile_landscape: { columns: 6, minWidth: 480, maxWidth: 767, gap: 8, padding: 16 },
tablet_portrait: { columns: 8, minWidth: 768, maxWidth: 1023, gap: 12, padding: 20 },
tablet_landscape: { columns: 12, minWidth: 1024, gap: 12, padding: 24 },
};
// 모드 감지 (순수 너비 기반)
function detectGridMode(viewportWidth: number): GridMode {
if (viewportWidth < 480) return "mobile_portrait";
if (viewportWidth < 768) return "mobile_landscape";
if (viewportWidth < 1024) return "tablet_portrait";
return "tablet_landscape";
}
// 레이아웃 데이터
interface PopLayoutDataV5 {
version: "pop-5.0";

View File

@ -12,6 +12,266 @@
---
## [2026-02-06] v5.2.1 그리드 셀 크기 강제 고정
### 배경 (왜 이 작업이 필요했는가)
**문제 상황**:
- 4칸 모드에서 특정 행의 가이드 셀이 다른 행보다 작게 표시됨
- `gridAutoRows`는 최소 높이만 보장하여, 컴포넌트 콘텐츠가 행 높이를 밀어내면 인접 빈 셀도 영향받음
- "셀의 크기 = 컴포넌트의 크기"라는 핵심 설계 원칙이 시각적으로 깨짐
### Changed
- **gridAutoRows → gridTemplateRows** (PopRenderer.tsx)
```typescript
// 변경 전: 최소 높이만 보장 (콘텐츠에 따라 늘어남)
gridAutoRows: `${breakpoint.rowHeight}px`
// 변경 후: 행 높이 강제 고정
gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`
gridAutoRows: `${breakpoint.rowHeight}px` // 동적 추가행 대비 유지
```
- **dynamicRowCount 분리** (PopRenderer.tsx)
- gridCells 내부 → 독립 useMemo로 분리
- gridStyle과 gridCells에서 공유
- **컴포넌트 overflow 변경** (PopRenderer.tsx)
- `overflow-visible``overflow-hidden`
- 컴포넌트 콘텐츠가 셀 경계를 벗어나지 않도록 강제
### Fixed
- **PopRenderer dynamicRowCount에서 숨김 컴포넌트 포함 문제**
- PopCanvas는 숨김 제외하여 높이 계산, PopRenderer는 포함하여 계산 → 기준 불일치
- PopRenderer에도 숨김 필터 추가, 여유행 +5 → +3으로 통일
- **디버깅 console.log 잔존** (PopCanvas.tsx)
- reviewComponents useMemo 내 console.log 2개 삭제
- **뷰어 viewportWidth 선언 순서** (page.tsx)
- currentModeKey보다 뒤에 선언되어 있던 viewportWidth를 앞으로 이동
### 수정 파일
| 파일 | 변경 내용 |
|------|----------|
| `PopRenderer.tsx` | gridTemplateRows 강제 고정, dynamicRowCount 분리, overflow-hidden, 숨김 필터 추가 |
| `PopCanvas.tsx` | 디버깅 console.log 삭제 |
| `page.tsx (뷰어)` | viewportWidth 선언 순서 수정 |
---
## [2026-02-06] v5.2 브레이크포인트 재설계 + 세로 자동 확장
### 배경 (왜 이 작업이 필요했는가)
**문제 상황**:
- 뷰어에서 브라우저 수동 리사이즈 시 768~839px 구간에서 모드 불일치
- useResponsiveMode 훅과 GRID_BREAKPOINTS 상수 간 기준 불일치
- 기존 브레이크포인트가 실제 기기 뷰포트와 맞지 않음
**사용자 요구사항**:
- "현장 모바일 기기 8~14인치, 핸드폰은 아이폰 미니 ~ 갤럭시 울트라"
- "세로는 신경쓸 필요 없고 무한 스크롤 가능해야 함"
### Changed
- **브레이크포인트 재설계** (pop-layout.ts)
| 모드 | 변경 전 | 변경 후 | 근거 |
|------|--------|--------|------|
| mobile_portrait | ~599px | ~479px | 스마트폰 세로 최대 440px |
| mobile_landscape | 600~839px | 480~767px | 스마트폰 가로 |
| tablet_portrait | 840~1023px | 768~1023px | iPad Mini 768px 포함 |
| tablet_landscape | 1024px+ | 동일 | - |
- **detectGridMode() 조건 수정** (pop-layout.ts)
```typescript
if (viewportWidth < 480) return "mobile_portrait"; // was 600
if (viewportWidth < 768) return "mobile_landscape"; // was 840
if (viewportWidth < 1024) return "tablet_portrait";
```
- **BREAKPOINTS.TABLET_MIN 변경** (useDeviceOrientation.ts)
- 768 (was 840)
- **VIEWPORT_PRESETS에서 height 제거** (PopCanvas.tsx)
- width만 유지, 세로는 무한 스크롤
### Added
- **세로 자동 확장** (PopCanvas.tsx)
- `MIN_CANVAS_HEIGHT = 600`: 최소 캔버스 높이
- `CANVAS_EXTRA_ROWS = 3`: 항상 유지되는 여유 행 수
- `dynamicCanvasHeight`: 컴포넌트 배치 기반 동적 계산
- **격자 셀 동적 계산** (PopRenderer.tsx)
- 고정 20행 → maxRowEnd + 5 동적 계산
- **뷰어 일관성 확보** (page.tsx)
- 프리뷰 모드: useResponsiveModeWithOverride 유지
- 일반 모드: detectGridMode(viewportWidth) 직접 사용
### Fixed
- **뷰어 반응형 모드 불일치**
- 768~839px 구간에서 6칸/8칸 모드 불일치 해결
- **hiddenComponentIds 중복 정의 에러**
- 라인 410-412 중복 useMemo 제거
### Technical Details
```
브레이크포인트 재설계 근거 (실제 기기 CSS 뷰포트):
| 기기 | CSS 뷰포트 너비 |
|------|----------------|
| iPhone SE | 375px |
| iPhone 16 Pro | 402px |
| Galaxy S25 Ultra | 440px |
| iPad Mini 7 | 768px |
| iPad Pro 11 | 834px (세로), 1194px (가로) |
| iPad Pro 13 | 1024px (세로), 1366px (가로) |
→ 768px, 1024px가 업계 표준 (Tailwind, Bootstrap 동일)
```
```
세로 자동 확장 로직:
const dynamicCanvasHeight = useMemo(() => {
const maxRowEnd = visibleComps.reduce((max, comp) => {
return Math.max(max, comp.row + comp.rowSpan);
}, 1);
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS; // +3행 여유
const height = totalRows * (rowHeight + gap) + padding * 2;
return Math.max(MIN_CANVAS_HEIGHT, height); // 최소 600px
}, [...]);
```
### 수정 파일
| 파일 | 변경 내용 |
|------|----------|
| `pop-layout.ts` | GRID_BREAKPOINTS 값 수정, detectGridMode() 조건 수정 |
| `useDeviceOrientation.ts` | BREAKPOINTS.TABLET_MIN = 768 |
| `PopCanvas.tsx` | VIEWPORT_PRESETS height 제거, dynamicCanvasHeight 추가 |
| `PopRenderer.tsx` | gridCells 동적 행 수 계산 |
| `page.tsx (뷰어)` | detectGridMode() 사용 |
---
## [2026-02-06] v5.1 자동 줄바꿈 + 검토 필요 시스템
### 배경 (왜 이 작업이 필요했는가)
**문제 상황**:
- 12칸에서 배치한 컴포넌트가 4칸 모드로 전환하면 "화면 밖" 패널로 이동하여 뷰어에서 안 보임
- 사용자가 모든 모드를 수동으로 편집해야 하는 부담
- "화면 밖" 개념이 실제로는 "검토 필요" 알림 역할이었음
**해결 방향**:
- 자동 줄바꿈: col > maxCol인 컴포넌트를 자동으로 맨 아래에 배치
- 정보 손실 방지: 모든 컴포넌트가 항상 그리드 안에 표시됨
- 검토 필요 알림: 오버라이드 없으면 "검토 필요" 표시 (자동 배치 상태)
### Added
- **자동 줄바꿈 로직** (gridUtils.ts)
- `convertAndResolvePositions()` 수정
- 원본 col 보존 로직 추가
- 정상 컴포넌트 vs 초과 컴포넌트 분리
- 초과 컴포넌트를 맨 아래에 순차 배치 (col=1, row=맨아래+1)
- colSpan 자동 축소 (targetColumns 초과 방지)
- **검토 필요 판별 함수** (gridUtils.ts)
- `needsReview()` 신규 함수
- 기준: 12칸 아니고 + 오버라이드 없으면 → 검토 필요
- 간단한 로직: "이 모드에서 편집했냐 안 했냐"
- **검토 필요 패널** (PopCanvas.tsx)
- `ReviewPanel`: "화면 밖" → "검토 필요"로 이름 변경
- `ReviewItem`: 클릭 시 해당 컴포넌트 선택 (드래그 없음)
- 자동 배치 뱃지 표시
- 파란색 테마 (경고 아닌 안내 느낌)
### Changed
- **isOutOfBounds() Deprecated** (gridUtils.ts)
- `@deprecated` 주석 추가
- needsReview()로 대체 권장
- 하위 호환을 위해 함수는 유지
- **"화면 밖" 패널 역할 변경** (PopCanvas.tsx)
- 기존: col > maxCol → 화면 밖 (드래그로 복원)
- 변경: 오버라이드 없음 → 검토 필요 (클릭으로 선택)
- 숨김 기능과 완전히 분리 (별도 유지)
### Fixed
- **정보 손실 문제 해결**
- 모든 컴포넌트가 항상 그리드 안에 배치됨
- 뷰어에서도 자동 배치가 적용되어 모두 표시됨
### Technical Details
```
자동 줄바꿈 로직:
1. convertAndResolvePositions() 호출
components: [ {id: "A", position: {col:1, ...}}, {id: "B", position: {col:5, ...}} ]
targetMode: "mobile_portrait" (4칸)
2. 비율 변환 + 원본 col 보존
converted: [
{id: "A", position: {col:1, ...}, originalCol: 1},
{id: "B", position: {col:2, ...}, originalCol: 5} // col은 변환됨, 원본은 5
]
3. 정상 vs 초과 분리
normalComponents: [A] // originalCol ≤ 4
overflowComponents: [B] // originalCol > 4
4. 맨 아래 배치
maxRow = A의 (row + rowSpan - 1) = 1
B: col=1, row=2 (맨 아래에 자동 배치)
5. 겹침 해결
resolveOverlaps([A, B], 4) // 최종 위치 확정
6. 검토 필요 판별
needsReview("mobile_portrait", false) // 오버라이드 없음 → true
→ ReviewPanel에 B 표시
```
```
검토 필요 vs 숨김:
구분 | 검토 필요 | 숨김
------------- | ---------------------- | -------------------
역할 | 자동 배치 알림 | 의도적 숨김
뷰어에서 | 보임 (자동 배치) | 안 보임
디자이너에서 | ReviewPanel 표시 | HiddenPanel 표시
판단 기준 | 오버라이드 없음 | hidden 배열에 ID
색상 테마 | 파란색 (안내) | 회색 (제외)
```
### 수정 파일
| 파일 | 변경 내용 |
|------|----------|
| `gridUtils.ts` | convertAndResolvePositions 자동 줄바꿈, needsReview 추가, isOutOfBounds deprecated |
| `PopCanvas.tsx` | OutOfBoundsPanel → ReviewPanel 변경, needsReview 필터링 |
| `PopRenderer.tsx` | isOutOfBounds import 제거 (사용 안 함) |
| `README.md` | v5.1 버전 표시, 최신 기능 요약 |
| `CHANGELOG.md` | v5.1 항목 추가 |
---
## [2026-02-05 심야] 반응형 레이아웃 + 숨김 기능 완성
### 배경 (왜 이 작업이 필요했는가)
@ -71,6 +331,10 @@
- **getAllEffectivePositions에 숨김 컴포넌트 포함**
- 해결: 숨김 및 화면밖 컴포넌트를 결과에서 제외
- **Expected drag drop context 에러 (뷰어 페이지)**
- 원인: `DraggableComponent`에서 `useDrag` 훅이 `DndProvider` 없이 호출됨
- 해결: `isDesignMode=false`일 때 `DraggableComponent` 대신 일반 `div`로 렌더링
### Changed
- **PopModeOverrideV5 타입 확장**

View File

@ -1,6 +1,6 @@
# POP 파일 상세 목록
**최종 업데이트: 2026-02-05 저녁 (드래그앤드롭 수정)**
**최종 업데이트: 2026-02-06 (v5.2 브레이크포인트 재설계 + 세로 자동 확장)**
이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다.
@ -172,15 +172,20 @@ interface PopCanvasProps {
}
```
**뷰포트 프리셋**:
**뷰포트 프리셋** (v5.2 - height 제거됨, 세로 자동 확장):
```typescript
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", width: 375, height: 667 }, // 4칸
{ id: "mobile_landscape", label: "모바일 가로", width: 667, height: 375 }, // 6칸
{ id: "tablet_portrait", label: "태블릿 세로", width: 768, height: 1024 }, // 8칸
{ id: "tablet_landscape", label: "태블릿 가로", width: 1024, height: 768 }, // 12칸
{ id: "mobile_portrait", label: "모바일 세로", width: 375, columns: 4 },
{ id: "mobile_landscape", label: "모바일 가로", width: 600, columns: 6 },
{ id: "tablet_portrait", label: "태블릿 세로", width: 834, columns: 8 },
{ id: "tablet_landscape", label: "태블릿 가로", width: 1024, columns: 12 },
];
// 세로 자동 확장
const MIN_CANVAS_HEIGHT = 600;
const CANVAS_EXTRA_ROWS = 3;
const dynamicCanvasHeight = useMemo(() => { ... }, []);
```
**제공 기능**:
@ -251,7 +256,7 @@ export { default as ComponentEditorPanel, default } from "./ComponentEditorPanel
|------|------|
| 역할 | v5 레이아웃 CSS Grid 렌더러 + 격자 셀 |
| 입력 | PopLayoutDataV5, viewportWidth, currentMode, showGridGuide |
| 격자 | 12x20 = 240개 실제 DOM 셀 (CSS Grid 좌표계) |
| 격자 | 동적 행 수 (컴포넌트 배치에 따라 자동 계산, CSS Grid 좌표계) |
**핵심 Props**:
@ -272,16 +277,22 @@ interface PopRendererProps {
**격자 셀 렌더링**:
```typescript
// 12x20 = 240개 셀 생성
// 동적 행 수 계산 (컴포넌트 배치 기반)
const gridCells = useMemo(() => {
const maxRowEnd = Object.values(components).reduce((max, comp) => {
const pos = getEffectivePosition(comp);
return Math.max(max, pos.row + pos.rowSpan);
}, 1);
const rowCount = Math.max(10, maxRowEnd + 5);
const cells = [];
for (let row = 1; row <= 20; row++) {
for (let col = 1; col <= 12; col++) {
cells.push({ id: `${col}-${row}`, col, row });
for (let row = 1; row <= rowCount; row++) {
for (let col = 1; col <= breakpoint.columns; col++) {
cells.push({ id: `cell-${col}-${row}`, col, row });
}
}
return cells;
}, []);
}, [components, overrides, mode, breakpoint.columns]);
// 컴포넌트와 동일한 CSS Grid 좌표계로 렌더링
{showGridGuide && gridCells.map(cell => (

View File

@ -19,11 +19,20 @@
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 격자 셀 | PopRenderer.tsx | `gridCells` | CSS Grid 기반 격자선 |
| 격자 셀 | PopRenderer.tsx | `gridCells` | CSS Grid 기반 격자선 (동적 행 수) |
| 열 라벨 | PopCanvas.tsx | `gridLabels.columns` | 1~12 표시 |
| 행 라벨 | PopCanvas.tsx | `gridLabels.rows` | 1~20 표시 |
| 행 라벨 | PopCanvas.tsx | `gridLabels.rows` | 동적 계산 (dynamicCanvasHeight 기반) |
| 토글 | PopCanvas.tsx | `showGridGuide` 상태 | 격자 ON/OFF |
## 세로 자동 확장
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 동적 높이 | PopCanvas.tsx | `dynamicCanvasHeight` | 컴포넌트 배치 기반 자동 계산 |
| 최소 높이 | PopCanvas.tsx | `MIN_CANVAS_HEIGHT` | 600px 보장 |
| 여유 행 | PopCanvas.tsx | `CANVAS_EXTRA_ROWS` | 항상 3행 추가 |
| 격자 행 수 | PopRenderer.tsx | `gridCells` | maxRowEnd + 5 동적 계산 |
## 드래그 앤 드롭
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
@ -71,9 +80,26 @@
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 프리셋 전환 | PopCanvas.tsx | `VIEWPORT_PRESETS` | 4개 모드 |
| 프리셋 전환 | PopCanvas.tsx | `VIEWPORT_PRESETS` | 4개 모드 (width만, height 제거) |
| 줌 컨트롤 | PopCanvas.tsx | `canvasScale` | 30%~150% |
| 패닝 | PopCanvas.tsx | Space + 드래그 | 캔버스 이동 |
| 모드 감지 | pop-layout.ts | `detectGridMode()` | 너비 기반 모드 판별 |
## 브레이크포인트
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 그리드 설정 | pop-layout.ts | `GRID_BREAKPOINTS` | 모드별 칸 수, gap, padding |
| 모드 감지 | pop-layout.ts | `detectGridMode()` | viewportWidth → GridMode |
| 훅 연동 | useDeviceOrientation.ts | `BREAKPOINTS.TABLET_MIN` | 768px (태블릿 경계) |
## 자동 줄바꿈/검토
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 자동 배치 | gridUtils.ts | `convertAndResolvePositions()` | col > maxCol → 맨 아래 배치 |
| 검토 필요 판별 | gridUtils.ts | `needsReview()` | 오버라이드 없으면 true |
| 검토 패널 | PopCanvas.tsx | `ReviewPanel` | 검토 필요 컴포넌트 목록 |
---

View File

@ -2,130 +2,80 @@
---
## 현재 상태 (2026-02-04)
## 현재 상태 (2026-02-06)
**v4 통합 설계 모드 Phase 1.6 완료 (비율 스케일링 시스템)**
### 완료된 작업
1. **v4 기본 구조** - 완료
2. **v4 렌더러** - 완료 (PopFlexRenderer)
3. **v4 디자이너 통합** - 완료
4. **새 화면 v4 기본 적용** - 완료
5. **Undo/Redo** - 완료 (데스크탑 모드와 동일 방식)
6. **드래그 리사이즈** - 완료
7. **Flexbox 가로 배치** - 완료 (업계 표준 방식)
8. **Spacer 컴포넌트** - 완료
9. **컴포넌트 순서 변경** - 완료 (드래그 앤 드롭)
10. **비율 스케일링** - 완료 (업계 표준 Scale with Fixed Aspect Ratio)
### 현재 UI 상태
```
┌─────────────────────────────────────────────────────────────────┐
│ ← 목록 화면명 *변경됨 [↶][↷] 자동 레이아웃 (v4) [저장] │
├─────────────────────────────────────────────────────────────────┤
│ 미리보기: [모바일↕] [모바일↔] [태블릿↕] [태블릿↔(기본)] │
│ 너비: [========●====] 1024 x 768 70% [-][+] │
├─────────────────────────────────────────────────────────────────┤
│ │ │ │
│ 컴포넌트 │ [필드1] [필드2] [필드3] [필드4] │ 속성 패널 │
│ 팔레트 │ [필드5] [Spacer] [Spacer] │ │
│ (20%) │ (가로 배치 + 줄바꿈) │ (20%) │
│ │ │ │
│ - 필드 │ 디바이스 스크린 (스크롤 가능) │ │
│ - 버튼 │ │ │
│ - 리스트 │ │ │
│ - 인디케이터│ │ │
│ - 스캐너 │ │ │
│ - 숫자패드 │ │ │
│ - 스페이서 │ │ │
└──────────┴────────────────────────────────────┴─────────────────┘
```
**v5.2 그리드 시스템 완성 (브레이크포인트 재설계 + 세로 자동 확장)**
---
## 작업 순서
```
[Phase 1~3] [Phase 4] [Phase 5]
v4 Flexbox → 실제 컴포넌트 → 그리드 시스템 (v5)
완료 다음 계획 승인
[Phase 1~3] [Phase 5] [Phase 4]
v4 Flexbox → v5 CSS Grid → 실제 컴포넌트 구현
완료 완료 (v5.2) 다음
```
### Phase 5: 그리드 시스템 (v5) - 신규 계획
```
[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4]
그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화
```
**상세 계획**: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md)
---
## Phase 1: 기본 구조 (완료)
## 완료된 Phase
- [x] v3/v4 탭 제거 (자동 판별)
- [x] 새 화면 → v4로 시작
- [x] 기존 v3 화면 → v3로 로드 (하위 호환)
- [x] 4개 프리셋 버튼 (모바일↕, 모바일↔, 태블릿↕, 태블릿↔)
- [x] 슬라이더 유지 (320~1200px)
- [x] 기본 프리셋: 태블릿 가로 (1024x768)
- [x] ComponentPaletteV4 생성 (v4 전용 팔레트)
- [x] 빈 레이아웃도 v4로 시작하도록 로직 수정
### Phase 1~3: v4 Flexbox 시스템 (완료, 레거시 삭제됨)
## Phase 1.5: Flexbox 가로 배치 + 기본 기능 (완료)
v4 Flexbox 기반 시스템은 v5 CSS Grid로 완전히 대체되었습니다.
v4 관련 파일은 모두 삭제되었습니다.
- [x] Undo/Redo (Ctrl+Z / Ctrl+Shift+Z)
- [x] 드래그 리사이즈 핸들 (오른쪽, 아래, 오른쪽아래)
- [x] Flexbox 가로 배치 (`direction: horizontal`, `wrap: true`)
- [x] 컴포넌트 타입별 기본 크기 설정
- [x] Spacer 컴포넌트 (`pop-spacer`) - 정렬용 빈 공간
- [x] 컴포넌트 순서 변경 (드래그 앤 드롭)
- [x] 디바이스 스크린 스크롤 (무한 스크롤)
- [x] v4 기본 구조, 렌더러, 디자이너 통합
- [x] Undo/Redo, 드래그 리사이즈, Flexbox 가로 배치
- [x] 비율 스케일링 시스템
- [x] 오버라이드 기능 (모드별 배치 고정)
- [x] 컴포넌트 표시/숨김, 줄바꿈
## Phase 1.6: 비율 스케일링 시스템 (완료)
### Phase 5: v5 CSS Grid 시스템 (완료)
- [x] 기준 너비 설정 (BASE_VIEWPORT_WIDTH = 1024px)
- [x] 최대 너비 제한 (1366px, 12인치)
- [x] 뷰포트 감지 (resize 이벤트 리스너)
- [x] 컴포넌트 크기 스케일 적용 (fixedWidth, fixedHeight)
- [x] 컨테이너 스케일 적용 (gap, padding)
- [x] 디자인 모드 분리 (scale=1, 원본 유지)
- [x] DndProvider 에러 수정 (뷰어에서 useDrag/useDrop 방지)
#### Phase 5.1: 타입 정의 (완료)
- [x] `PopLayoutDataV5` 인터페이스
- [x] `PopGridConfig`, `PopGridPosition` 타입
- [x] `GridMode`, `GRID_BREAKPOINTS` 상수
- [x] `createEmptyPopLayoutV5()`, `isV5Layout()`, `detectGridMode()`
## Phase 2: 오버라이드 기능 (완료) ✅
#### Phase 5.2: 그리드 렌더러 (완료)
- [x] `PopRenderer.tsx` - CSS Grid 기반 렌더링
- [x] 격자 셀 렌더링 (CSS Grid 동일 좌표계)
- [x] 위치 변환 (12칸 -> 4/6/8칸)
### Phase 2.1: 배치 고정 (완료)
- [x] 현재 모드 추적 (PopDesigner)
- [x] 고정 버튼 UI 및 로직
- [x] 오버라이드 저장 (배치만)
- [x] 오버라이드 초기화 로직
- [x] **버그 수정**: tempLayout 도입 (root 오염 방지)
- [x] **속성 패널**: 다른 모드에서 비활성화
#### Phase 5.3: 디자이너 UI (완료)
- [x] `PopCanvas.tsx` - 그리드 캔버스 + 행/열 라벨
- [x] 드래그 스냅 (칸에 맞춤)
- [x] `ComponentEditorPanel.tsx` - 위치 편집
### Phase 2.2: 렌더러 오버라이드 적용 (완료) ✅
- [x] PopFlexRenderer에서 오버라이드 병합 (getMergedRoot)
- [x] 컨테이너 속성 오버라이드 적용 (direction, wrap, gap, alignItems, justifyContent, padding, children)
- [x] tempLayout 우선 표시 (고정 전 미리보기)
- [x] 테스트 (모드별 다른 배치)
#### Phase 5.4: 반응형 자동화 (완료)
- [x] 자동 변환 알고리즘 (12칸 -> 4칸)
- [x] 겹침 감지 및 재배치
- [x] 모드별 오버라이드 저장
### Phase 2.3: 편집 자동 감지 (완료) ✅
- [x] 순서 변경 시 자동 tempLayout 저장
- [x] "고정" 버튼으로 정식 오버라이드 전환
- [x] 속성 변경은 기본 모드에서만 가능 (다른 모드 차단)
#### v5.1 추가 기능 (완료)
- [x] 자동 줄바꿈 (col > maxCol -> 맨 아래 배치)
- [x] "검토 필요" 알림 시스템
- [x] Gap 프리셋 (좁게/보통/넓게)
- [x] 숨김 기능 (모드별)
## Phase 3: 컴포넌트 표시/숨김 + 줄바꿈 (완료) ✅
#### v5.2 브레이크포인트 재설계 + 세로 자동 확장 (완료)
- [x] 기기 기반 브레이크포인트 (479/767/1023px)
- [x] 세로 자동 확장 (dynamicCanvasHeight)
- [x] 뷰어 반응형 일관성 (detectGridMode 사용)
- [x] VIEWPORT_PRESETS에서 height 제거
- [x] visibility 속성 추가 (모드별 true/false)
- [x] 속성 패널 "표시" 탭 추가 (체크박스 UI)
- [x] 렌더러에서 visibility 처리
- [x] pop-break 컴포넌트 추가 (강제 줄바꿈)
- [x] 컴포넌트 오버라이드 병합 로직
- [x] 삭제 시 오버라이드 정리 로직
---
## Phase 4: 실제 컴포넌트 구현 (다음)
## 다음 작업
### Phase 4: 실제 컴포넌트 구현
현재 모든 컴포넌트는 `pop-sample` (샘플 박스)로 렌더링됩니다.
실제 컴포넌트를 구현하여 데이터 바인딩까지 연결해야 합니다.
**컴포넌트 구현 목록**:
- [ ] pop-field: 입력/표시 필드
- [ ] pop-button: 액션 버튼
@ -134,172 +84,37 @@ v4 Flexbox → 실제 컴포넌트 → 그리드 시스템 (v5)
- [ ] pop-scanner: 바코드/QR 스캔
- [ ] pop-numpad: 숫자 입력 패드
---
**참고 문서**: [components-spec.md](./components-spec.md)
## Phase 5: 그리드 시스템 (v5) - 계획 승인
### 후속 작업
> 상세 계획: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md)
### 개요
Flexbox 흐름 기반 → **CSS Grid 위치 지정** 방식으로 전환
| 항목 | v4 (현재) | v5 (그리드) |
|------|----------|-------------|
| 배치 | Flexbox 흐름 | Grid 좌표 (열/행) |
| 크기 | 픽셀 (200px) | 칸 (colSpan, rowSpan) |
| 줄바꿈 | 자동 | 명시적 |
### Phase 5.1: 그리드 타입 정의
- [ ] `PopLayoutDataV5` 인터페이스
- [ ] `PopGridConfig` (칸 수, 행 높이, 간격)
- [ ] `PopComponentPositionV5` (col, row, colSpan, rowSpan)
- [ ] 브레이크포인트 상수 (4칸/6칸/8칸/12칸)
### Phase 5.2: 그리드 렌더러
- [ ] `PopGridRenderer.tsx` 생성
- [ ] CSS Grid 스타일 계산
- [ ] 브레이크포인트 감지 및 칸 수 변경
- [ ] 위치 변환 (12칸 → 4칸)
### Phase 5.3: 디자이너 UI
- [ ] `PopCanvasV5.tsx` (그리드 캔버스)
- [ ] 바둑판 배경 표시
- [ ] 드래그 스냅 (칸에 맞춤)
- [ ] 위치 편집 패널
### Phase 5.4: 반응형 자동화
- [ ] 자동 변환 알고리즘 (12칸 → 4칸)
- [ ] 겹침 감지 및 재배치
- [ ] 모드별 오버라이드
### 브레이크포인트
| 모드 | 화면 범위 | 그리드 칸 수 |
|------|----------|-------------|
| 모바일 세로 | ~599px (4~6인치) | 4칸 |
| 모바일 가로 | 600~839px (6~8인치) | 6칸 |
| 태블릿 세로 | 840~1023px (8~10인치) | 8칸 |
| 태블릿 가로 | 1024px~ (10~14인치) | 12칸 |
- [ ] 워크플로우 연동 (버튼 액션, 화면 전환)
- [ ] 데이터 바인딩 연결
- [ ] 실기기 테스트 (아이폰 SE, iPad Mini 등)
---
## 완료된 기능 목록
## 브레이크포인트 (v5.2 현재)
### v4 타입 정의
- [x] `PopLayoutDataV4` - 단일 소스 레이아웃
- [x] `PopContainerV4` - 스택 컨테이너
- [x] `PopSizeConstraintV4` - 크기 규칙 (fixed/fill/hug)
- [x] `PopResponsiveRuleV4` - 반응형 규칙
- [x] `createEmptyPopLayoutV4()` - 생성 함수
- [x] `isV4Layout()` - 타입 가드
- [x] `addComponentToV4Layout()` - 컴포넌트 추가
- [x] `removeComponentFromV4Layout()` - 컴포넌트 삭제
- [x] `updateComponentInV4Layout()` - 컴포넌트 수정
- [x] `updateContainerV4()` - 컨테이너 수정
- [x] `findContainerV4()` - 컨테이너 찾기
### v4 렌더러
- [x] `PopFlexRenderer` - Flexbox 기반 렌더링
- [x] 컨테이너 재귀 렌더링 (`ContainerRenderer`)
- [x] 반응형 규칙 적용 (`applyResponsiveRules`)
- [x] 컴포넌트 숨김 처리 (`hideBelow`)
- [x] 크기 제약 → CSS 변환 (`calculateSizeStyle`)
- [x] 드래그 리사이즈 핸들 (`ComponentRendererV4`)
- [x] 드래그 앤 드롭 순서 변경 (`DraggableComponentWrapper`)
- [x] 비율 스케일링 (`BASE_VIEWPORT_WIDTH`, scale 계산)
### v4 캔버스
- [x] `PopCanvasV4` - v4 전용 캔버스
- [x] 뷰포트 프리셋 (4개 모드)
- [x] 너비 슬라이더 (320~1200px)
- [x] 줌 컨트롤 (30%~150%)
- [x] 패닝 (Space + 드래그)
- [x] 드래그 앤 드롭
### v4 속성 패널
- [x] `ComponentEditorPanelV4` - 속성 편집 패널
- [x] 크기 제약 편집 (fixed/fill/hug)
- [x] 컨테이너 설정 (방향, 정렬, 간격)
### 디자이너 통합
- [x] `PopDesigner` v3/v4 자동 판별
- [x] 새 화면 v4 기본 적용
- [x] 기존 v3 화면 하위 호환
- [x] `ComponentPaletteV4` v4 전용 팔레트 (Spacer 포함)
- [x] Undo/Redo 버튼 및 단축키
- [x] 컴포넌트 순서 변경 핸들러 (`handleReorderComponentV4`)
| 모드 | 화면 너비 | 칸 수 | 대상 기기 |
|------|----------|-------|----------|
| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S |
| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로 |
| tablet_portrait | 768~1023px | 8칸 | 8~10인치 태블릿 세로 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 |
---
## v3 vs v4 비교
## 관련 문서
| 항목 | v3 (기존) | v4 (새로운) |
|------|-----------|-------------|
| 설계 | 4모드 각각 | 1번만 |
| 데이터 | col, row 위치 | 규칙 (fill/fixed/hug) |
| 렌더링 | CSS Grid | Flexbox |
| 반응형 | 수동 | 자동 + 규칙 |
| 새 화면 | - | 기본 적용 |
---
## 관련 파일
| 파일 | 역할 |
| 문서 | 내용 |
|------|------|
| `PopDesigner.tsx` | v3/v4 통합 디자이너 |
| `PopCanvasV4.tsx` | v4 캔버스 (4개 프리셋 + 슬라이더) |
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 + 비율 스케일링 |
| `ComponentPaletteV4.tsx` | v4 컴포넌트 팔레트 |
| `ComponentEditorPanelV4.tsx` | v4 속성 편집 패널 |
| `pop-layout.ts` | v3/v4 타입 정의 |
| `page.tsx` (뷰어) | v4 레이아웃 뷰어 + viewportWidth 감지 |
| [STATUS.md](./STATUS.md) | 현재 진행 상태 |
| [SPEC.md](./SPEC.md) | 기술 스펙 |
| [ARCHITECTURE.md](./ARCHITECTURE.md) | 코드 구조 |
| [components-spec.md](./components-spec.md) | 컴포넌트 상세 설계 |
| [decisions/005](./decisions/005-breakpoint-redesign.md) | 브레이크포인트 재설계 ADR |
---
## 비율 스케일링 시스템
### 개념
10인치(1024px) 기준으로 디자인하면, 8~12인치 화면에서 배치는 유지하고 크기만 비례 조정
### 계산 공식
```
scale = viewportWidth / BASE_VIEWPORT_WIDTH (1024)
scaledSize = originalSize * scale
```
### 적용 범위
| 항목 | 스케일 적용 |
|------|------------|
| fixedWidth | O |
| fixedHeight | O |
| minWidth/maxWidth | O |
| minHeight | O |
| gap | O |
| padding | O |
| flex (fill) | X (비율 기반) |
| hug | X (내용 기반) |
### 화면별 스케일
| 화면 | 너비 | 스케일 | 200px → |
|------|------|--------|---------|
| 8인치 | 800px | 0.78 | 156px |
| 10인치 | 1024px | 1.00 | 200px |
| 12인치 | 1366px | 1.33 | 266px |
| 14인치+ | 1366px (max) | 1.33 | 266px + 여백 |
---
*최종 업데이트: 2026-02-05 (Phase 5 그리드 시스템 계획 추가)*
*최종 업데이트: 2026-02-06 (v5.2 완료, Phase 4 대기)*

View File

@ -11,6 +11,8 @@
|------|------|------|--------|
| rowSpan이 적용 안됨 | gridTemplateRows를 `1fr`로 변경 | 2026-02-02 | grid, rowSpan, CSS |
| 컴포넌트 크기 스케일 안됨 | viewportWidth 기반 scale 계산 추가 | 2026-02-04 | scale, viewport, 반응형 |
| **그리드 가이드 셀 크기 불균일** | gridAutoRows → gridTemplateRows로 행 높이 강제 고정 | 2026-02-06 | gridAutoRows, gridTemplateRows, 셀 크기, CSS Grid |
| **컴포넌트 콘텐츠가 셀 경계 벗어남** | overflow-visible → overflow-hidden 변경 | 2026-02-06 | overflow, 셀 크기, 콘텐츠 |
## DnD (드래그앤드롭) 관련
@ -18,6 +20,7 @@
|------|------|------|--------|
| useDrag 에러 (뷰어에서) | isDesignMode 체크 후 early return | 2026-02-04 | DnD, useDrag, 뷰어 |
| DndProvider 중복 에러 | 최상위에서만 Provider 사용 | 2026-02-04 | DndProvider, react-dnd |
| **Expected drag drop context (뷰어)** | isDesignMode=false일 때 DraggableComponent 대신 일반 div 렌더링 | 2026-02-05 | DndProvider, useDrag, 뷰어, context |
| **컴포넌트 중첩(겹침)** | toast import 누락 → `sonner`에서 import | 2026-02-05 | 겹침, overlap, toast |
| **리사이즈 핸들 작동 안됨** | useDrop 2개 중복 → 단일 useDrop으로 통합 | 2026-02-05 | resize, 핸들, useDrop |
| **드래그 좌표 완전 틀림 (Row 92)** | 캔버스 scale 보정 누락 → `(offset - rect.left) / scale` | 2026-02-05 | scale, 좌표, transform |
@ -35,10 +38,19 @@
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| **화면 밖 컴포넌트 정보 손실** | 자동 줄바꿈 로직 추가 (col > maxCol → col=1, row=맨아래+1) | 2026-02-06 | 자동배치, 줄바꿈, 정보손실 |
| Flexbox 배치 예측 불가 | CSS Grid로 전환 (v5) | 2026-02-05 | Flexbox, Grid, 반응형 |
| 4모드 각각 배치 힘듦 | 제약조건 기반 시스템 (v4) | 2026-02-03 | 모드, 반응형, 제약조건 |
| 4모드 자동 전환 안됨 | useResponsiveMode 훅 추가 | 2026-02-01 | 모드, 훅, 반응형 |
## 브레이크포인트/반응형 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| **뷰어 반응형 모드 불일치** | detectGridMode() 사용으로 일관성 확보 | 2026-02-06 | 반응형, 뷰어, 모드 |
| **768~839px 모드 불일치** | TABLET_MIN 768로 변경, 브레이크포인트 재설계 | 2026-02-06 | 브레이크포인트, 768px |
| **useResponsiveMode vs GRID_BREAKPOINTS 불일치** | 뷰어에서 detectGridMode(viewportWidth) 사용 | 2026-02-06 | 훅, 상수, 일관성 |
## 저장/로드 관련
| 문제 | 해결 | 날짜 | 키워드 |
@ -77,6 +89,14 @@
| 컴포넌트 이동 안됨 | **해결** | useDrop/useDrag 타입 통일 |
| 컴포넌트 중첩(겹침) | **해결** | toast import 추가 → 겹침 감지 로직 정상 작동 |
| 리사이즈 핸들 작동 안됨 | **해결** | useDrop 통합 (2개 → 1개) |
| 숨김 컴포넌트 드래그 안됨 | **해결** | handleMoveComponent에서 숨김 해제 + 위치 저장 단일 상태 업데이트 |
| 그리드 범위 초과 에러 | **해결** | adjustedCol 계산으로 드롭 위치 자동 조정 |
| Expected drag drop context (뷰어) | **해결** | isDesignMode=false일 때 일반 div 렌더링 |
| hiddenComponentIds 중복 정의 | **해결** | 중복 useMemo 제거 (라인 410-412) |
| 뷰어 반응형 모드 불일치 | **해결** | detectGridMode() 사용 |
| 그리드 가이드 셀 크기 불균일 | **해결** | gridTemplateRows로 행 높이 강제 고정 |
| Canvas vs Renderer 행 수 불일치 | **해결** | 숨김 필터 통일, 여유행 +3으로 통일 |
| 디버깅 console.log 잔존 | **해결** | reviewComponents 내 console.log 삭제 |
---
@ -114,4 +134,111 @@ calcGridPosition(relX, relY, customWidth, ...);
---
## Expected drag drop context 에러 상세 (2026-02-05 심야)
### 증상
```
Invariant Violation: Expected drag drop context
at useDrag (...)
at DraggableComponent (...)
```
뷰어 페이지(`/pop/viewer/[screenId]`)에서 POP 화면 조회 시 에러 발생
### 원인
```
PopRenderer의 DraggableComponent에서 useDrag 훅을 무조건 호출
→ 뷰어 페이지에는 DndProvider가 없음
→ React 훅은 조건부 호출 불가 (Rules of Hooks)
→ DndProvider 없이 useDrag 호출 시 context 에러
```
### 해결
```typescript
// PopRenderer.tsx - 컴포넌트 렌더링 부분
if (isDesignMode) {
return (
<DraggableComponent ... /> // useDrag 사용
);
}
// 뷰어 모드: 드래그 없는 일반 렌더링
return (
<div className="..." style={positionStyle}>
<ComponentContent ... />
</div>
);
```
### 교훈
> React DnD의 `useDrag`/`useDrop` 훅은 반드시 `DndProvider` 내부에서만 호출해야 함.
> 디자인 모드와 뷰어 모드를 분기할 때, 훅이 포함된 컴포넌트 자체를 조건부 렌더링해야 함.
> 훅 내부에서 `canDrag: false`로 설정해도 훅 자체는 호출되므로 context 에러 발생.
### 관련 파일
- `gridUtils.ts`: convertAndResolvePositions(), needsReview()
- `PopCanvas.tsx`: ReviewPanel, ReviewItem
- `PopRenderer.tsx`: 자동 배치 위치 렌더링
---
## 뷰어 반응형 모드 불일치 상세 (2026-02-06)
### 증상
```
- 아이폰 SE, iPad Pro 프리셋은 정상 작동
- 브라우저 수동 리사이즈 시 6칸 모드(mobile_landscape)가 적용 안 됨
- 768~839px 구간에서 8칸으로 표시됨 (예상: 6칸)
```
### 원인
```
useResponsiveMode 훅:
- deviceType: width/height 비율로 "mobile"/"tablet" 판정
- isLandscape: width > height로 판정
- BREAKPOINTS.TABLET_MIN = 840 (당시)
GRID_BREAKPOINTS:
- mobile_landscape: 600~839px (6칸)
- tablet_portrait: 840~1023px (8칸)
결과:
- 768px 화면 → useResponsiveMode: "tablet" (768 < 840이지만 비율 판정)
- 768px 화면 → GRID_BREAKPOINTS: "mobile_landscape" (6칸)
- → 모드 불일치!
```
### 해결
**1단계: 브레이크포인트 재설계**
```typescript
// 기존
mobile_landscape: { minWidth: 600, maxWidth: 839 }
tablet_portrait: { minWidth: 840, maxWidth: 1023 }
// 변경 후
mobile_landscape: { minWidth: 480, maxWidth: 767 }
tablet_portrait: { minWidth: 768, maxWidth: 1023 }
```
**2단계: 훅 연동**
```typescript
// useDeviceOrientation.ts
BREAKPOINTS.TABLET_MIN: 768 // was 840
```
**3단계: 뷰어 모드 감지 방식 변경**
```typescript
// page.tsx (뷰어)
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape) // 프리뷰: 수동 선택
: detectGridMode(viewportWidth); // 일반: 너비 기반 (일관성 확보)
```
### 교훈
> 반응형 모드 판정은 **단일 소스(GRID_BREAKPOINTS)**를 기준으로 해야 함.
> 훅과 상수가 각각 다른 기준을 사용하면 구간별 불일치 발생.
> 뷰어에서는 `detectGridMode(viewportWidth)` 직접 사용으로 일관성 확보.
---
*새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가*

View File

@ -9,22 +9,20 @@
| 항목 | 값 |
|------|-----|
| 버전 | **v5** (CSS Grid 기반) |
| 상태 | **반응형 레이아웃 + 숨김 기능 완료** |
| 버전 | **v5.2** (브레이크포인트 재설계 + 세로 자동 확장) |
| 상태 | **반응형 시스템 완성** |
| 다음 | Phase 4 (실제 컴포넌트 구현) |
**마지막 업데이트**: 2026-02-05 심야
**마지막 업데이트**: 2026-02-06
---
## 마지막 대화 요약
> **반응형 레이아웃 시스템 완성**:
> - 모드별 컴포넌트 재배치 (오버라이드) 시스템 구현
> - 화면 밖 컴포넌트 오른쪽 패널 배치 기능
> - 컴포넌트 숨김/숨김해제 기능 (모드별)
> - 리사이즈 겹침 검사 추가
> - H키 단축키로 숨김 처리
> **v5.2.1 그리드 셀 크기 강제 고정**:
> - gridAutoRows → gridTemplateRows로 행 높이 강제 고정
> - "셀의 크기 = 컴포넌트의 크기" 원칙을 코드 수준에서 강제
> - Canvas/Renderer 간 행 수 계산 기준 통일 (숨김 필터, 여유행 +3)
>
> 다음: Phase 4 (실제 컴포넌트 구현)
@ -38,6 +36,10 @@
| 저장/조회 규칙 | [SAVE_RULES.md](./SAVE_RULES.md) |
| 왜 v5로 바꿨어? | [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) |
| 그리드 가이드 설계 | [decisions/004-grid-guide-integration.md](./decisions/004-grid-guide-integration.md) |
| 브레이크포인트 재설계 | [decisions/005-breakpoint-redesign.md](./decisions/005-breakpoint-redesign.md) |
| 자동 줄바꿈 시스템 | [decisions/006-auto-wrap-review-system.md](./decisions/006-auto-wrap-review-system.md) |
| 개발 계획/로드맵 | [PLAN.md](./PLAN.md) |
| 컴포넌트 상세 설계 | [components-spec.md](./components-spec.md) |
| 이전 문제 해결 | [PROBLEMS.md](./PROBLEMS.md) |
| 코드 어디 있어? | [FILES.md](./FILES.md) |
| 기능별 색인 | [INDEX.md](./INDEX.md) |
@ -88,15 +90,17 @@ decisions/, sessions/, archive/
## v5 그리드 시스템 (현재)
| 모드 | 화면 너비 | 칸 수 |
|------|----------|-------|
| mobile_portrait | ~599px | 4칸 |
| mobile_landscape | 600~839px | 6칸 |
| tablet_portrait | 840~1023px | 8칸 |
| tablet_landscape | 1024px~ | 12칸 |
| 모드 | 화면 너비 | 칸 수 | 대상 기기 |
|------|----------|-------|----------|
| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S |
| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로 |
| tablet_portrait | 768~1023px | 8칸 | 8~10인치 태블릿 세로 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 |
**핵심**: 컴포넌트를 칸 단위로 배치 (col, row, colSpan, rowSpan)
**세로 무한 스크롤**: 캔버스 높이 자동 확장 (컴포넌트 배치에 따라)
**그리드 가이드**: CSS Grid 기반 격자 셀 + 행/열 라벨 (ON/OFF 토글)
---

View File

@ -8,12 +8,14 @@
### 1. 그리드 시스템
| 모드 | 화면 너비 | 칸 수 | 대상 |
|------|----------|-------|------|
| mobile_portrait | ~599px | 4칸 | 4~6인치 |
| mobile_landscape | 600~839px | 6칸 | 7인치 |
| tablet_portrait | 840~1023px | 8칸 | 8~10인치 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 (기본) |
| 모드 | 화면 너비 | 칸 수 | 대상 기기 |
|------|----------|-------|----------|
| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S (세로) |
| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로, 작은 태블릿 |
| tablet_portrait | 768~1023px | 8칸 | iPad Mini ~ iPad Pro (세로) |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 (기본) |
> **브레이크포인트 기준**: 실제 기기 CSS 뷰포트 너비 기반 (2026-02-06 재설계)
### 2. 위치 지정
@ -34,29 +36,59 @@ const GRID_BREAKPOINTS = {
columns: 4,
rowHeight: 48,
gap: 8,
padding: 12
padding: 12,
maxWidth: 479, // 아이폰 SE (375px) ~ 갤럭시 S (360px)
},
mobile_landscape: {
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16
padding: 16,
minWidth: 480,
maxWidth: 767, // 스마트폰 가로
},
tablet_portrait: {
columns: 8,
rowHeight: 52,
gap: 12,
padding: 20
padding: 20,
minWidth: 768, // iPad Mini 세로 (768px)
maxWidth: 1023,
},
tablet_landscape: {
columns: 12,
rowHeight: 56,
gap: 12,
padding: 24
padding: 24,
minWidth: 1024, // iPad Pro 11 가로 (1194px), 12.9 가로 (1366px)
},
};
```
### 4. 세로 자동 확장
```typescript
// 캔버스 높이 동적 계산
const MIN_CANVAS_HEIGHT = 600; // 최소 높이 (px)
const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수
const dynamicCanvasHeight = useMemo(() => {
// 가장 아래 컴포넌트 위치 계산
const maxRowEnd = visibleComps.reduce((max, comp) => {
const rowEnd = pos.row + pos.rowSpan;
return Math.max(max, rowEnd);
}, 1);
// 여유 행 추가하여 높이 계산
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
return Math.max(MIN_CANVAS_HEIGHT, totalRows * rowHeight + padding);
}, [layout.components, ...]);
```
**특징**:
- 디자이너: 세로 무한 확장 (컴포넌트 추가에 제한 없음)
- 뷰어: 터치 스크롤로 아래 컴포넌트 접근 가능
---
## 데이터 구조

View File

@ -1,6 +1,6 @@
# 현재 상태
> **마지막 업데이트**: 2026-02-05 심야
> **마지막 업데이트**: 2026-02-06
> **담당**: POP 화면 디자이너
---
@ -19,10 +19,16 @@
| 컴포넌트 팔레트 | 완료 | `ComponentPalette.tsx` |
| 드래그앤드롭 | 완료 | 스케일 보정, DND 상수 통합 |
| 그리드 가이드 재설계 | 완료 | CSS Grid 기반 통합 |
| **모드별 오버라이드** | **완료** | 위치/크기 모드별 저장 |
| **화면 밖 컴포넌트** | **완료** | 오른쪽 패널 배치, 드래그로 복원 |
| **숨김 기능** | **완료** | 모드별 숨김/숨김해제 |
| **리사이즈 겹침 검사** | **완료** | 실시간 겹침 방지 |
| 모드별 오버라이드 | 완료 | 위치/크기 모드별 저장 |
| 화면 밖 컴포넌트 | 완료 | 오른쪽 패널 배치, 드래그로 복원 |
| 숨김 기능 | 완료 | 모드별 숨김/숨김해제 |
| 리사이즈 겹침 검사 | 완료 | 실시간 겹침 방지 |
| Gap 프리셋 | 완료 | 좁게/보통/넓게 간격 조정 |
| **자동 줄바꿈** | **완료** | col > maxCol → 맨 아래 배치 |
| **검토 필요 시스템** | **완료** | 오버라이드 없으면 검토 알림 |
| **브레이크포인트 재설계** | **완료** | 기기 기반 (479/767/1023px) |
| **세로 자동 확장** | **완료** | 캔버스 높이 동적 계산 |
| **그리드 셀 크기 강제 고정** | **완료** | gridTemplateRows로 행 높이 고정, overflow-hidden |
---
@ -38,29 +44,39 @@
---
## 최근 주요 변경 (2026-02-05 심야)
## 최근 주요 변경 (2026-02-06)
### 반응형 레이아웃 시스템
### 브레이크포인트 재설계
| 모드 | 변경 전 | 변경 후 | 근거 |
|------|--------|--------|------|
| mobile_portrait | ~599px | ~479px | 스마트폰 세로 |
| mobile_landscape | 600~839px | 480~767px | 스마트폰 가로 |
| tablet_portrait | 840~1023px | 768~1023px | iPad Mini 포함 |
| tablet_landscape | 1024px+ | 동일 | - |
### 세로 자동 확장
| 기능 | 설명 |
|------|------|
| 동적 캔버스 높이 | 컴포넌트 배치에 따라 자동 계산 |
| 최소 높이 | 600px 보장 |
| 여유 행 | 항상 3행 추가 |
| 뷰어 스크롤 | 터치 스크롤로 아래 컴포넌트 접근 |
### v5.1 자동 줄바꿈 시스템
| 기능 | 설명 |
|------|------|
| 자동 줄바꿈 | col > maxCol인 컴포넌트를 맨 아래에 자동 배치 |
| 정보 손실 방지 | 모든 컴포넌트가 항상 그리드 안에 표시됨 |
| 검토 필요 알림 | 오버라이드 없으면 "검토 필요" 패널 표시 |
| 검토 완료 | 편집하면 오버라이드 저장, 검토 필요에서 제거 |
### 기존 기능 유지 (2026-02-05 심야)
| 기능 | 설명 |
|------|------|
| 모드별 재배치 | 4/6/8/12칸 모드별로 컴포넌트 위치/크기 개별 저장 |
| 자동 레이아웃 고정 | 드래그/리사이즈 시 자동으로 오버라이드 저장 |
| 원본으로 되돌리기 | 오버라이드 삭제하여 자동 재배치로 복원 |
### 화면 밖 컴포넌트 처리
| 기능 | 설명 |
|------|------|
| 오른쪽 패널 표시 | 현재 모드에서 초과하는 컴포넌트 별도 표시 |
| 드래그로 복원 | 패널에서 그리드로 드래그하여 재배치 |
| 위치 자동 조정 | 그리드 범위 초과 시 자동으로 왼쪽으로 밀어서 배치 |
### 숨김 기능
| 기능 | 설명 |
|------|------|
| 모드별 숨김 | 특정 모드에서만 컴포넌트 숨김 가능 |
| 숨김 방법 | 드래그→숨김패널 / H키 / 화면밖 컴포넌트 클릭 |
| 숨김 해제 | 숨김패널에서 그리드로 드래그 |
| 12칸 모드 제한 | 기본 모드(12칸)에서는 숨김 기능 비활성화 |
| 숨김 기능 | 특정 모드에서 컴포넌트 의도적 숨김 (검토와 별개) |
---
@ -74,6 +90,14 @@
| DND 타입 상수 불일치 | 해결됨 | constants/dnd.ts로 통합 |
| 숨김 컴포넌트 드래그 안됨 | 해결됨 | 상태 업데이트 순서 수정 |
| 그리드 범위 초과 에러 | 해결됨 | 드롭 위치 자동 조정 |
| Expected drag drop context | 해결됨 | 뷰어 모드에서 일반 div 렌더링 |
| Gap 프리셋 UI 안 보임 | 해결됨 | 그리드 라벨에 adjustedGap 적용 |
| 화면 밖 컴포넌트 정보 손실 | 해결됨 | 자동 줄바꿈으로 항상 그리드 안에 배치 |
| 뷰어 반응형 모드 불일치 | 해결됨 | detectGridMode() 사용으로 일관성 확보 |
| hiddenComponentIds 중복 정의 | 해결됨 | 중복 useMemo 제거 |
| 그리드 가이드 셀 크기 불균일 | 해결됨 | gridTemplateRows로 행 높이 강제 고정 |
| Canvas/Renderer 행 수 불일치 | 해결됨 | 숨김 필터 통일, 여유행 +3 |
| 디버깅 console.log 잔존 | 해결됨 | reviewComponents 내 삭제 |
---
@ -81,7 +105,8 @@
| 날짜 | 요약 | 상세 |
|------|------|------|
| 2026-02-05 심야 | 반응형 레이아웃, 숨김 기능, 겹침 검사 | 이 세션 |
| 2026-02-06 | 브레이크포인트 재설계, 세로 자동 확장, v5.1 자동 줄바꿈 | [sessions/2026-02-06.md](./sessions/2026-02-06.md) |
| 2026-02-05 심야 | 반응형 레이아웃, 숨김 기능, 겹침 검사 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) |
| 2026-02-05 저녁 | v5 통합, 그리드 가이드 재설계 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) |
---
@ -90,7 +115,8 @@
| ADR | 제목 | 날짜 |
|-----|------|------|
| 005 | 반응형 레이아웃 및 숨김 기능 | 2026-02-05 |
| 006 | v5.1 자동 줄바꿈 + 검토 필요 시스템 | 2026-02-06 |
| 005 | 브레이크포인트 재설계 (기기 기반) + 세로 자동 확장 | 2026-02-06 |
| 004 | 그리드 가이드 CSS Grid 통합 | 2026-02-05 |
| 003 | v5 CSS Grid 채택 | 2026-02-05 |
| 001 | v4 제약조건 기반 | 2026-02-03 |

View File

@ -0,0 +1,181 @@
# ADR 005: 브레이크포인트 재설계 (기기 기반)
**날짜**: 2026-02-06
**상태**: 채택
**의사결정자**: 시스템 아키텍트
---
## 상황 (Context)
### 문제 1: 뷰어에서 모드 전환 불일치
```
브라우저 수동 리사이즈 시:
- useResponsiveMode 훅: 768px 이상 → "tablet" 판정
- GRID_BREAKPOINTS: 768~839px → "mobile_landscape" (6칸)
결과: 768~839px 구간에서 모드 불일치 발생
```
### 문제 2: 기존 브레이크포인트 근거 부족
```
기존 설정:
- mobile_portrait: ~599px
- mobile_landscape: 600~839px
- tablet_portrait: 840~1023px
문제: 실제 기기 뷰포트와 맞지 않음
- iPad Mini 세로: 768px (mobile_landscape로 분류됨)
```
### 사용자 요구사항
> "현장 모바일 기기가 최소 8인치 ~ 최대 14인치,
> 핸드폰은 아이폰 미니 ~ 갤럭시 울트라 사이즈"
---
## 연구 (Research)
### 실제 기기 CSS 뷰포트 조사 (2026년 기준)
| 기기 | 화면 크기 | CSS 뷰포트 너비 |
|------|----------|----------------|
| iPhone SE | 4.7" | 375px |
| iPhone 16 Pro | 6.3" | 402px |
| Galaxy S25 Ultra | 6.9" | 440px |
| iPad Mini 7 | 8.3" | 768px |
| iPad Pro 11 | 11" | 834px (세로), 1194px (가로) |
| iPad Pro 13 | 13" | 1024px (세로), 1366px (가로) |
### 업계 표준 브레이크포인트
| 프레임워크 | 모바일/태블릿 경계 | 태블릿/데스크톱 경계 |
|-----------|------------------|-------------------|
| Tailwind CSS | 768px | 1024px |
| Bootstrap 5 | 768px | 992px |
| Material Design 3 | 600px | 840px |
**공통점**: 768px, 1024px가 거의 표준
---
## 결정 (Decision)
### 채택: 기기 기반 브레이크포인트
| 모드 | 너비 범위 | 변경 전 | 근거 |
|------|----------|--------|------|
| mobile_portrait | 0~479px | 0~599px | 스마트폰 세로 최대 440px |
| mobile_landscape | 480~767px | 600~839px | 스마트폰 가로, 767px까지 |
| tablet_portrait | 768~1023px | 840~1023px | iPad Mini 768px 포함 |
| tablet_landscape | 1024px+ | 동일 | 대형 태블릿 가로 |
### 핵심 변경
```typescript
// pop-layout.ts - GRID_BREAKPOINTS
mobile_portrait: { maxWidth: 479 } // was 599
mobile_landscape: { minWidth: 480, maxWidth: 767 } // was 600, 839
tablet_portrait: { minWidth: 768, maxWidth: 1023 } // was 840, 1023
tablet_landscape: { minWidth: 1024 } // 동일
// pop-layout.ts - detectGridMode()
if (viewportWidth < 480) return "mobile_portrait"; // was 600
if (viewportWidth < 768) return "mobile_landscape"; // was 840
if (viewportWidth < 1024) return "tablet_portrait";
// useDeviceOrientation.ts - BREAKPOINTS
TABLET_MIN: 768 // was 840
```
---
## 구현 (Implementation)
### 수정 파일
| 파일 | 변경 내용 |
|------|----------|
| `pop-layout.ts` | GRID_BREAKPOINTS 값 수정, detectGridMode() 조건 수정 |
| `useDeviceOrientation.ts` | BREAKPOINTS.TABLET_MIN = 768 |
| `PopCanvas.tsx` | VIEWPORT_PRESETS width 값 조정 |
| `page.tsx (뷰어)` | detectGridMode() 사용으로 일관성 확보 |
### 뷰어 모드 감지 방식 변경
```typescript
// 변경 전: useResponsiveModeWithOverride만 사용
const currentModeKey = getModeKey(deviceType, isLandscape);
// 변경 후: 프리뷰 모드와 일반 모드 분리
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape) // 프리뷰: 수동 선택
: detectGridMode(viewportWidth); // 일반: 너비 기반
```
---
## 결과 (Consequences)
### 긍정적 효과
| 효과 | 설명 |
|------|------|
| **기기 커버리지** | 아이폰 SE ~ 갤럭시 울트라, 8~14인치 태블릿 모두 포함 |
| **업계 표준 호환** | 768px, 1024px는 거의 모든 프레임워크 기준점 |
| **일관성 확보** | GRID_BREAKPOINTS와 detectGridMode() 완전 일치 |
| **직관적 매핑** | 스마트폰 세로/가로, 태블릿 세로/가로 자연스럽게 분류 |
### 트레이드오프
| 항목 | 설명 |
|------|------|
| **기존 데이터 영향** | 600~767px 구간이 6칸→6칸 (영향 없음) |
| **768~839px 변경** | 기존 6칸→8칸 (태블릿으로 재분류) |
---
## 세로 자동 확장 (추가 결정)
### 배경
> "세로는 신경쓸 필요가 없는 것 맞지?
> 그렇다면 캔버스도 세로 무한 스크롤이 가능해야겠네?"
### 결정
1. **뷰포트 프리셋에서 height 제거** (width만 유지)
2. **캔버스 높이 동적 계산** (컴포넌트 배치 기준)
3. **항상 여유 행 3개 유지** (추가 배치 공간)
4. **뷰어에서 터치 스크롤** 지원
### 구현
```typescript
// PopCanvas.tsx
const MIN_CANVAS_HEIGHT = 600;
const CANVAS_EXTRA_ROWS = 3;
const dynamicCanvasHeight = useMemo(() => {
const maxRowEnd = visibleComps.reduce((max, comp) => {
return Math.max(max, comp.row + comp.rowSpan);
}, 1);
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
return Math.max(MIN_CANVAS_HEIGHT, totalRows * rowHeight);
}, [layout.components, ...]);
```
---
## 관련 문서
- [003-v5-grid-system.md](./003-v5-grid-system.md) - v5 그리드 시스템 채택
- [006-auto-wrap-review-system.md](./006-auto-wrap-review-system.md) - 자동 줄바꿈
---
**결론**: 실제 기기 뷰포트 기반 브레이크포인트로 일관성 확보 + 세로 무한 스크롤로 UX 개선

View File

@ -0,0 +1,220 @@
# ADR 006: v5.1 자동 줄바꿈 + 검토 필요 시스템
**날짜**: 2026-02-06
**상태**: 채택
**의사결정자**: 시스템 아키텍트
---
## 상황 (Context)
v5 반응형 레이아웃에서 "화면 밖" 개념으로 컴포넌트를 처리했으나, 다음 문제가 발생했습니다:
### 문제 1: 정보 손실
```
12칸 모드:
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ A │ B (col=5, 6칸) │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
4칸 모드 (기존):
┌────┬────┬────┬────┐ 화면 밖:
│ A │ │ - B
└────┴────┴────┴────┘
↑ A만 보임 ↑ 뷰어에서 안 보임!
```
### 문제 2: 사용자 의도 불일치
사용자가 기대한 "화면 밖" 역할:
- ❌ 컴포넌트 숨김 (현재 동작)
- ✅ "이 컴포넌트 검토 필요" 알림
---
## 결정 (Decision)
### 채택: 자동 줄바꿈 + 검토 필요 시스템
```
col > maxCol → 자동으로 맨 아래에 배치 (줄바꿈)
오버라이드 없음 → "검토 필요" 알림
```
---
## 구현 (Implementation)
### 1. 자동 줄바꿈 로직
**파일**: `gridUtils.ts` - `convertAndResolvePositions()`
```typescript
// 단계별 처리:
1. 비율 변환 + 원본 col 보존
converted = components.map(comp => ({
id: comp.id,
position: convertPositionToMode(comp.position, targetMode),
originalCol: comp.position.col, // ⭐ 원본 보존
}))
2. 정상 vs 초과 분리
normalComponents = originalCol ≤ targetColumns
overflowComponents = originalCol > targetColumns
3. 초과 컴포넌트 자동 배치
maxRow = normalComponents의 최대 row
overflowComponents → col=1, row=맨아래+1
4. 겹침 해결
resolveOverlaps([...normalComponents, ...wrappedComponents])
```
### 2. 검토 필요 판별
**파일**: `gridUtils.ts` - `needsReview()`
```typescript
function needsReview(
currentMode: GridMode,
hasOverride: boolean
): boolean {
// 12칸 모드는 기본 모드이므로 검토 불필요
if (GRID_BREAKPOINTS[currentMode].columns === 12) return false;
// 오버라이드가 있으면 이미 편집함 → 검토 완료
if (hasOverride) return false;
// 오버라이드 없으면 → 검토 필요
return true;
}
```
**판단 기준 (최종)**: "이 모드에서 편집했냐 안 했냐"
### 3. 검토 필요 패널
**파일**: `PopCanvas.tsx` - `ReviewPanel`
```typescript
// 필터링
const reviewComponents = visibleComponents.filter(comp => {
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id];
return needsReview(currentMode, hasOverride);
});
// UI
<ReviewPanel
components={reviewComponents}
onSelectComponent={onSelectComponent} // 클릭 시 선택
/>
```
**변경 사항**:
- 기존: `OutOfBoundsPanel` (주황색, 드래그로 복원)
- 변경: `ReviewPanel` (파란색, 클릭으로 선택)
---
## 결과 (Consequences)
### 긍정적 효과
| 효과 | 설명 |
|------|------|
| **정보 손실 방지** | 모든 컴포넌트가 항상 그리드 안에 표시됨 |
| **사용자 부담 감소** | 자동 배치를 먼저 제공, 필요시에만 편집 |
| **의도 명확화** | "숨김" ≠ "검토 필요" (기능 분리) |
| **뷰어 호환** | 자동 배치가 뷰어에도 적용됨 |
### 트레이드오프
| 항목 | 설명 |
|------|------|
| **스크롤 증가** | 아래로 자동 배치되면 페이지가 길어질 수 있음 |
| **자동 배치 품질** | 사용자가 원하지 않는 위치에 배치될 수 있음 |
---
## 사용자 시나리오
### 시나리오 1: 수용 (자동 배치 그대로)
```
1. 12칸에서 컴포넌트 A, B, C 배치
2. 4칸 모드로 전환
3. 시스템: 자동 배치 + "검토 필요 (3개)" 알림
4. 사용자: 확인 → "괜찮네" → 아무것도 안 함
5. 결과: 자동 배치 유지 (오버라이드 없음)
```
### 시나리오 2: 편집 (오버라이드 저장)
```
1. 12칸에서 컴포넌트 A, B, C 배치
2. 4칸 모드로 전환
3. 시스템: 자동 배치 + "검토 필요 (3개)" 알림
4. 사용자: A 클릭 → 드래그/리사이즈
5. 결과: A 오버라이드 저장 → A 검토 완료
6. "검토 필요 (2개)" (B, C만 남음)
```
### 시나리오 3: 보류 (나중에)
```
1. 12칸에서 컴포넌트 A, B, C 배치
2. 4칸 모드로 전환
3. 시스템: 자동 배치 + "검토 필요 (3개)" 알림
4. 사용자: 다른 모드로 전환 또는 저장
5. 결과: 자동 배치 유지, 나중에도 "검토 필요" 표시
```
---
## 기능 비교
| 구분 | 역할 | 뷰어에서 | 판단 기준 |
|------|------|---------|----------|
| **검토 필요** | 자동 배치 알림 | **보임** | 오버라이드 없음 |
| **숨김** | 의도적 숨김 | **안 보임** | hidden 배열에 ID |
---
## 대안 (Alternatives Considered)
### A안: 완전 자동 (채택 ✅)
- 모든 초과 컴포넌트 자동 배치
- "검토 필요" 알림으로 확인 유도
- 업계 표준 (Webflow, Retool)
### B안: 선택적 자동 (미채택)
- 첫 전환 시만 자동 배치
- 사용자가 원하면 "화면 밖"으로 드래그
- 복잡성 증가
### C안: 수동 배치 유지 (미채택)
- 기존 "화면 밖" 패널 유지
- 사용자가 모든 모드 수동 편집
- 사용자 부담 과다
---
## 참고 자료
### 업계 표준 (2026년 기준)
- **Grafana, Tableau**: Masonry Layout (조적식)
- **Retool, PowerApps**: Vertical Stacking (수직 스택)
- **Webflow, Framer**: CSS Grid Auto-Placement
**공통점**: "Fluid Reflow (유동적 재배치)" - 정보 손실 방지
---
## 관련 파일
| 파일 | 변경 내용 |
|------|----------|
| `gridUtils.ts` | convertAndResolvePositions, needsReview 추가 |
| `PopCanvas.tsx` | ReviewPanel로 변경 |
| `PopRenderer.tsx` | isOutOfBounds import 제거 |
| `pop-layout.ts` | 타입 변경 없음 (기존 구조 유지) |
---
**결론**: 자동 줄바꿈 + 검토 필요 시스템으로 정보 손실 방지 및 사용자 부담 최소화

View File

@ -52,7 +52,7 @@ v5 그리드 시스템 통합 완료, 그리드 가이드 재설계, **드래그
## 미완료
- [x] 실제 화면 테스트 (디자이너 페이지) → 완료, 정상 작동
- [ ] 간격 조정 규칙 결정 (전역 고정 vs 화면별 vs 컴포넌트별)
- [x] 간격 조정 규칙 결정 → 2026-02-06 Gap 프리셋으로 해결 (좁게/보통/넓게)
---

View File

@ -0,0 +1,239 @@
# 2026-02-06 작업 기록
## 요약
v5.1 자동 줄바꿈 + 검토 필요 시스템 완성, 브레이크포인트 재설계, 세로 자동 확장 구현
---
## 완료
### 브레이크포인트 재설계
- [x] GRID_BREAKPOINTS 값 수정 (기기 기반)
- [x] detectGridMode() 조건 수정
- [x] useDeviceOrientation.ts TABLET_MIN 768로 변경
- [x] 뷰어에서 detectGridMode() 사용하여 일관성 확보
### 세로 자동 확장
- [x] VIEWPORT_PRESETS에서 height 속성 제거
- [x] dynamicCanvasHeight useMemo 추가
- [x] MIN_CANVAS_HEIGHT, CANVAS_EXTRA_ROWS 상수 추가
- [x] gridLabels 동적 계산 (행 수 자동 조정)
- [x] gridCells 동적 계산 (PopRenderer)
- [x] 뷰어 프리뷰 모드 스크롤 지원
### 자동 줄바꿈 시스템 (v5.1)
- [x] convertAndResolvePositions() 자동 줄바꿈 로직
- [x] 원본 col 보존 로직
- [x] 초과 컴포넌트 맨 아래 배치
- [x] colSpan 자동 축소
### 검토 필요 시스템
- [x] needsReview() 함수 추가
- [x] OutOfBoundsPanel → ReviewPanel 변경
- [x] 파란색 테마 (안내 느낌)
- [x] 클릭 시 컴포넌트 선택
### 버그 수정
- [x] hiddenComponentIds 중복 정의 에러 수정
- [x] useDrop 의존성 배열 수정
- [x] 검토 필요 패널 모드별 표시 불일치 수정
### 그리드 셀 크기 강제 고정 (v5.2.1)
- [x] gridAutoRows → gridTemplateRows 변경 (행 높이 강제 고정)
- [x] dynamicRowCount를 gridStyle과 gridCells에서 공유
- [x] 컴포넌트 overflow: visible → overflow: hidden 변경
- [x] PopRenderer dynamicRowCount에서 숨김 컴포넌트 제외
- [x] PopCanvas와 PopRenderer의 여유행 기준 통일 (+3)
- [x] 디버깅용 console.log 2개 삭제
- [x] 뷰어 page.tsx viewportWidth 선언 순서 수정
---
## 브레이크포인트 변경 상세
### 변경 전 → 변경 후
| 모드 | 변경 전 | 변경 후 | 근거 |
|------|--------|--------|------|
| mobile_portrait | ~599px | ~479px | 스마트폰 세로 최대 440px |
| mobile_landscape | 600~839px | 480~767px | 767px까지 스마트폰 |
| tablet_portrait | 840~1023px | 768~1023px | iPad Mini 768px 포함 |
| tablet_landscape | 1024px+ | 동일 | 변경 없음 |
### 연구 결과 (기기별 CSS 뷰포트)
| 기기 | CSS 뷰포트 너비 |
|------|----------------|
| iPhone SE | 375px |
| iPhone 16 Pro | 402px |
| Galaxy S25 Ultra | 440px |
| iPad Mini 7 | 768px |
| iPad Pro 11 | 834px (세로), 1194px (가로) |
| iPad Pro 13 | 1024px (세로), 1366px (가로) |
---
## 세로 자동 확장 상세
### 핵심 상수
```typescript
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 (px)
const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수
```
### 동적 높이 계산 로직
```typescript
const dynamicCanvasHeight = useMemo(() => {
const visibleComps = Object.values(layout.components)
.filter(comp => !hiddenComponentIds.includes(comp.id));
if (visibleComps.length === 0) return MIN_CANVAS_HEIGHT;
const maxRowEnd = visibleComps.reduce((max, comp) => {
const pos = getEffectivePosition(comp);
return Math.max(max, pos.row + pos.rowSpan);
}, 1);
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
const height = totalRows * (rowHeight + gap) + padding * 2;
return Math.max(MIN_CANVAS_HEIGHT, height);
}, [dependencies]);
```
### 영향받는 영역
| 영역 | 변경 |
|------|------|
| 캔버스 컨테이너 | minHeight: dynamicCanvasHeight |
| 디바이스 스크린 | minHeight: dynamicCanvasHeight |
| 행 라벨 | 동적 행 수 계산 |
| 격자 셀 | 동적 행 수 계산 |
---
## 자동 줄바꿈 로직 상세
### 처리 단계
```
1. 비율 변환 + 원본 col 보존
converted = map(comp => ({
position: convertPositionToMode(comp.position),
originalCol: comp.position.col, // 원본 보존
}))
2. 정상 vs 초과 분리
normalComponents = filter(originalCol <= targetColumns)
overflowComponents = filter(originalCol > targetColumns)
3. 초과 컴포넌트 맨 아래 배치
maxRow = normalComponents의 최대 (row + rowSpan - 1)
overflowComponents → col=1, row=maxRow+1
4. colSpan 자동 축소
if (colSpan > targetColumns) colSpan = targetColumns
5. 겹침 해결
resolveOverlaps([...normalComponents, ...wrappedComponents])
```
---
## 대화 핵심
### 반응형 불일치 문제
**사용자 리포트**:
> "아이폰 SE, iPad Pro 프리셋은 잘 되는데,
> 브라우저 수동 리사이즈 시 6칸 모드가 적용 안 되는 것 같아"
**원인 분석**:
- useResponsiveMode: width/height 비율로 landscape/portrait 판정
- GRID_BREAKPOINTS: 순수 너비 기반
- 768~839px 구간에서 불일치 발생
**해결**:
- 뷰어에서 detectGridMode(viewportWidth) 사용
- 프리뷰 모드만 useResponsiveModeWithOverride 유지
### 세로 무한 스크롤 결정
**사용자 질문**:
> "우리 화면 모드는 너비만 신경쓰면 되잖아?
> 세로는 무한 스크롤이 가능해야 하겠네?"
**확인 사항**:
1. 너비만 신경쓰면 됨 ✅
2. 캔버스 세로 무한 스크롤 필요 ✅
3. 뷰어에서 터치 스크롤 지원 ✅
**구현 방식 선택**:
- 수동 행 추가 방식 vs **자동 확장 방식 (채택)**
- 이유: 여유 공간 3행 자동 유지, 사용자 부담 최소화
---
## 빌드 결과
```
exit_code: 0
주요 변경 파일: 6개
```
---
## 관련 링크
- ADR: [decisions/005-breakpoint-redesign.md](../decisions/005-breakpoint-redesign.md)
- ADR: [decisions/006-auto-wrap-review-system.md](../decisions/006-auto-wrap-review-system.md)
- 이전 세션: [sessions/2026-02-05.md](./2026-02-05.md)
---
## 이번 작업에서 배운 것
### 새로 알게 된 기술 개념
- **gridAutoRows vs gridTemplateRows**: `gridAutoRows`는 행의 *최소* 높이만 보장하고 콘텐츠에 따라 늘어날 수 있음. `gridTemplateRows`는 행 높이를 *강제 고정*함. 가이드 셀과 컴포넌트가 같은 Grid 컨테이너에 있을 때, 컴포넌트 콘텐츠가 행 높이를 밀어내면 인접한 빈 가이드 셀 크기도 함께 변해 시각적 불일치가 발생함.
### 발생했던 에러와 원인 패턴
| 에러 | 원인 패턴 |
|------|-----------|
| 그리드 셀 크기 불균일 | 같은 CSS Grid에서 gridAutoRows(최소값)를 사용하면 콘텐츠가 행 높이를 변형시킴 |
| Canvas vs Renderer 행 수 불일치 | 같은 데이터(행 수)를 두 곳에서 계산하면서 필터 조건(숨김 제외)이 달랐음 |
| 디버깅 console.log 잔존 | 기능 완료 후 정리 단계를 생략함 |
| viewportWidth 참조 순서 | 변수 사용 코드가 선언 코드보다 위에 위치 (JS 호이스팅으로 동작은 하지만 가독성 저하) |
### 다음에 비슷한 작업할 때 주의할 점
1. **CSS Grid에서 "고정 크기" 셀이 필요하면 `gridTemplateRows`를 사용**하고, `gridAutoRows`는 동적 추가행 대비용으로만 유지
2. **같은 데이터를 여러 곳에서 계산할 때, 필터 조건이 동일한지 반드시 비교** (숨김 제외 등)
3. **기능 완료 후 `console.log`를 Grep으로 검색하여 디버깅 로그 정리**
4. **변수 선언 순서는 의존 관계 순서와 일치**시켜야 가독성과 유지보수성 확보
---
## 중단점
> **다음 작업**: Phase 4 실제 컴포넌트 구현
> - pop-label, pop-button 등 실제 렌더링 구현
> - 데이터 바인딩 연결
> - STATUS.md의 "다음 작업" 섹션 참조
---
## 다음 작업자 참고
1. **테스트 필요**
- 아이폰 SE 실기기 테스트
- iPad Mini 세로 모드 확인
- 브라우저 리사이즈로 모드 전환 확인
2. **향후 작업**
- Phase 4: 실제 컴포넌트 구현 (pop-label, pop-button 등)
- 데이터 바인딩 연결
- 워크플로우 연동