From 40219fed08fcab6806fbfbf7a2872df5021fe967 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Feb 2026 15:30:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-designer):=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브레이크포인트 재설계: 실제 기기 CSS 뷰포트 기반 (479/767/1023px) - 자동 줄바꿈 시스템: col > maxCol 컴포넌트 자동 재배치, 검토 필요 알림 - Gap 프리셋: 좁게/보통/넓게 3단계 간격 조절 - 셀 크기 강제 고정: gridTemplateRows + overflow-hidden - 세로 자동 확장: 동적 캔버스 높이 계산 (최소 600px) - 뷰어 모드 일관성: detectGridMode() 직접 사용 - 컴포넌트 ID 충돌 방지: 로드 시 idCounter 자동 설정 - popdocs 문서 정비: ADR 2건, 레거시 문서 archive 이동 Co-authored-by: Cursor --- .../src/controllers/screenGroupController.ts | 1 - .../app/(pop)/pop/screens/[screenId]/page.tsx | 54 ++- .../components/pop/designer/PopCanvas.tsx | 307 +++++++++------- .../components/pop/designer/PopDesigner.tsx | 38 +- .../designer/panels/ComponentEditorPanel.tsx | 2 +- .../pop/designer/renderers/PopRenderer.tsx | 159 +++++---- .../pop/designer/types/pop-layout.ts | 48 ++- .../pop/designer/utils/gridUtils.ts | 136 ++++++-- frontend/hooks/useDeviceOrientation.ts | 9 +- popdocs/ARCHITECTURE.md | 31 +- popdocs/CHANGELOG.md | 264 ++++++++++++++ popdocs/FILES.md | 35 +- popdocs/INDEX.md | 32 +- popdocs/PLAN.md | 327 ++++-------------- popdocs/PROBLEMS.md | 127 +++++++ popdocs/README.md | 34 +- popdocs/SPEC.md | 52 ++- popdocs/STATUS.md | 74 ++-- popdocs/{ => archive}/GRID_CODING_PLAN.md | 0 popdocs/{ => archive}/GRID_SYSTEM_DESIGN.md | 0 popdocs/{ => archive}/GRID_SYSTEM_PLAN.md | 0 popdocs/decisions/005-breakpoint-redesign.md | 181 ++++++++++ .../decisions/006-auto-wrap-review-system.md | 220 ++++++++++++ popdocs/sessions/2026-02-05.md | 2 +- popdocs/sessions/2026-02-06.md | 239 +++++++++++++ 25 files changed, 1784 insertions(+), 588 deletions(-) rename popdocs/{ => archive}/GRID_CODING_PLAN.md (100%) rename popdocs/{ => archive}/GRID_SYSTEM_DESIGN.md (100%) rename popdocs/{ => archive}/GRID_SYSTEM_PLAN.md (100%) create mode 100644 popdocs/decisions/005-breakpoint-redesign.md create mode 100644 popdocs/decisions/006-auto-wrap-review-system.md create mode 100644 popdocs/sessions/2026-02-06.md diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index f8fb1c09..d963aea6 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2701,4 +2701,3 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); } }; - diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 999c4b4b..f578b30e 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -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> = { +// 디바이스별 크기 (너비만, 높이는 콘텐츠 기반) +const DEVICE_SIZES: Record> = { 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() { )}
{/* v5 그리드 렌더러 */} {hasComponents ? (
- + {(() => { + // 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 ( + + ); + })()}
) : ( // 빈 화면 diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index 1b1d0e70..7753a992 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -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 (
@@ -457,7 +500,29 @@ export default function PopCanvas({ {/* 해상도 표시 */}
- {customWidth} × {customHeight} + {customWidth} × {Math.round(dynamicCanvasHeight)} +
+ +
+ + {/* Gap 프리셋 선택 */} +
+ 간격: +
@@ -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({
{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({
{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} /> )}
@@ -634,13 +701,12 @@ export default function PopCanvas({ className="flex flex-col gap-3" style={{ marginTop: "32px" }} > - {/* 초과 컴포넌트 패널 */} - {showOutOfBoundsPanel && ( - )} @@ -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 (
{/* 헤더 */} -
- - - 화면 밖 ({components.length}개) +
+ + + 검토 필요 ({components.length}개)
{/* 컴포넌트 목록 */}
{components.map((comp) => ( - onSelectComponent(comp.id)} - onHide={() => onHideComponent?.(comp.id)} /> ))}
{/* 안내 문구 */} -
-

- 드래그로 그리드 배치 / 클릭하면 숨김 처리 +

+

+ 자동 배치됨. 클릭하여 확인 후 편집 가능

); } +// ======================================== +// 검토 필요 아이템 (ReviewPanel 내부) +// ======================================== + +interface ReviewItemProps { + component: PopComponentDefinitionV5; + isSelected: boolean; + onSelect: () => void; +} + +function ReviewItem({ + component, + isSelected, + onSelect, +}: ReviewItemProps) { + return ( +
{ + e.stopPropagation(); + onSelect(); + }} + > + + {component.label || component.id} + + + 자동 배치됨 + +
+ ); +} + // ======================================== // 숨김 컴포넌트 영역 (오른쪽 패널) // ======================================== @@ -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 ( -
- {/* 컴포넌트 이름 */} -
- {component.label || component.type} -
- - {/* 원본 위치 정보 */} -
- 원본: {component.position.col}열, {component.position.row}행 - ({component.position.colSpan}×{component.position.rowSpan}) -
-
- ); -} // ======================================== // 숨김 컴포넌트 아이템 (드래그 가능) diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 1ee13c9a..efcc2c8e 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -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} /> diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 3bf5036c..4998a67d 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -66,7 +66,7 @@ export default function ComponentEditorPanel({ // 선택된 컴포넌트 없음 if (!component) { return ( -
+

속성

diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index e54918ec..301c78f8 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -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 ( + + ); + } + + // 뷰어 모드: 드래그 없는 일반 렌더링 return ( - + className="relative rounded-lg border-2 border-gray-200 bg-white transition-all overflow-hidden z-10" + style={positionStyle} + > + +
); })}
@@ -250,10 +281,11 @@ interface DraggableComponentProps { positionStyle: React.CSSProperties; isSelected: boolean; isDesignMode: boolean; - isOutOfBounds: boolean; breakpoint: GridBreakpoint; viewportWidth: number; allEffectivePositions: Map; + 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({
- {/* 리사이즈 핸들 (선택된 컴포넌트만, 초과 아닐 때만) */} - {isDesignMode && isSelected && !isOutOfBounds && onComponentResize && ( + {/* 리사이즈 핸들 (선택된 컴포넌트만) */} + {isDesignMode && isSelected && onComponentResize && ( @@ -349,6 +375,8 @@ interface ResizeHandlesProps { breakpoint: GridBreakpoint; viewportWidth: number; allEffectivePositions: Map; + 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} - - {/* 초과 표시 */} - {isOutOfBounds && ( - - 밖 - - )}
{/* 내용 */}
- + {typeLabel}
diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 4bfcbbc3..146af62c 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -119,11 +119,12 @@ export interface GridBreakpoint { /** * 브레이크포인트 상수 + * 업계 표준 (768px, 1024px) + 실제 기기 커버리지 기반 */ export const GRID_BREAKPOINTS: Record = { - // 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 = { 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 = { 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 = { 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 = { + 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", }, }); diff --git a/frontend/components/pop/designer/utils/gridUtils.ts b/frontend/components/pop/designer/utils/gridUtils.ts index 1c36b519..308ce730 100644 --- a/frontend/components/pop/designer/utils/gridUtils.ts +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -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); } }); diff --git a/frontend/hooks/useDeviceOrientation.ts b/frontend/hooks/useDeviceOrientation.ts index a493e487..2d9cf323 100644 --- a/frontend/hooks/useDeviceOrientation.ts +++ b/frontend/hooks/useDeviceOrientation.ts @@ -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 }; /** diff --git a/popdocs/ARCHITECTURE.md b/popdocs/ARCHITECTURE.md index bca80c28..8acbf24b 100644 --- a/popdocs/ARCHITECTURE.md +++ b/popdocs/ARCHITECTURE.md @@ -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"; diff --git a/popdocs/CHANGELOG.md b/popdocs/CHANGELOG.md index 04acea82..6085dab9 100644 --- a/popdocs/CHANGELOG.md +++ b/popdocs/CHANGELOG.md @@ -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 타입 확장** diff --git a/popdocs/FILES.md b/popdocs/FILES.md index da9b79dd..2504a542 100644 --- a/popdocs/FILES.md +++ b/popdocs/FILES.md @@ -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 => ( diff --git a/popdocs/INDEX.md b/popdocs/INDEX.md index 9ae595cd..275a5281 100644 --- a/popdocs/INDEX.md +++ b/popdocs/INDEX.md @@ -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` | 검토 필요 컴포넌트 목록 | --- diff --git a/popdocs/PLAN.md b/popdocs/PLAN.md index 18911719..95e18ac3 100644 --- a/popdocs/PLAN.md +++ b/popdocs/PLAN.md @@ -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 대기)* diff --git a/popdocs/PROBLEMS.md b/popdocs/PROBLEMS.md index 9f12bc0a..823f8988 100644 --- a/popdocs/PROBLEMS.md +++ b/popdocs/PROBLEMS.md @@ -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 ( + // useDrag 사용 + ); +} + +// 뷰어 모드: 드래그 없는 일반 렌더링 +return ( +
+ +
+); +``` + +### 교훈 +> 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)` 직접 사용으로 일관성 확보. + +--- + *새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가* diff --git a/popdocs/README.md b/popdocs/README.md index 3c3c4403..88a478e2 100644 --- a/popdocs/README.md +++ b/popdocs/README.md @@ -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 토글) --- diff --git a/popdocs/SPEC.md b/popdocs/SPEC.md index d5849298..dd391729 100644 --- a/popdocs/SPEC.md +++ b/popdocs/SPEC.md @@ -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, ...]); +``` + +**특징**: +- 디자이너: 세로 무한 확장 (컴포넌트 추가에 제한 없음) +- 뷰어: 터치 스크롤로 아래 컴포넌트 접근 가능 + --- ## 데이터 구조 diff --git a/popdocs/STATUS.md b/popdocs/STATUS.md index 72d83cec..b21e4547 100644 --- a/popdocs/STATUS.md +++ b/popdocs/STATUS.md @@ -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 | diff --git a/popdocs/GRID_CODING_PLAN.md b/popdocs/archive/GRID_CODING_PLAN.md similarity index 100% rename from popdocs/GRID_CODING_PLAN.md rename to popdocs/archive/GRID_CODING_PLAN.md diff --git a/popdocs/GRID_SYSTEM_DESIGN.md b/popdocs/archive/GRID_SYSTEM_DESIGN.md similarity index 100% rename from popdocs/GRID_SYSTEM_DESIGN.md rename to popdocs/archive/GRID_SYSTEM_DESIGN.md diff --git a/popdocs/GRID_SYSTEM_PLAN.md b/popdocs/archive/GRID_SYSTEM_PLAN.md similarity index 100% rename from popdocs/GRID_SYSTEM_PLAN.md rename to popdocs/archive/GRID_SYSTEM_PLAN.md diff --git a/popdocs/decisions/005-breakpoint-redesign.md b/popdocs/decisions/005-breakpoint-redesign.md new file mode 100644 index 00000000..3c51fef3 --- /dev/null +++ b/popdocs/decisions/005-breakpoint-redesign.md @@ -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 개선 diff --git a/popdocs/decisions/006-auto-wrap-review-system.md b/popdocs/decisions/006-auto-wrap-review-system.md new file mode 100644 index 00000000..0ad468d3 --- /dev/null +++ b/popdocs/decisions/006-auto-wrap-review-system.md @@ -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 + +``` + +**변경 사항**: +- 기존: `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` | 타입 변경 없음 (기존 구조 유지) | + +--- + +**결론**: 자동 줄바꿈 + 검토 필요 시스템으로 정보 손실 방지 및 사용자 부담 최소화 diff --git a/popdocs/sessions/2026-02-05.md b/popdocs/sessions/2026-02-05.md index d565c8bc..1507d885 100644 --- a/popdocs/sessions/2026-02-05.md +++ b/popdocs/sessions/2026-02-05.md @@ -52,7 +52,7 @@ v5 그리드 시스템 통합 완료, 그리드 가이드 재설계, **드래그 ## 미완료 - [x] 실제 화면 테스트 (디자이너 페이지) → 완료, 정상 작동 -- [ ] 간격 조정 규칙 결정 (전역 고정 vs 화면별 vs 컴포넌트별) +- [x] 간격 조정 규칙 결정 → 2026-02-06 Gap 프리셋으로 해결 (좁게/보통/넓게) --- diff --git a/popdocs/sessions/2026-02-06.md b/popdocs/sessions/2026-02-06.md new file mode 100644 index 00000000..4c5a6b61 --- /dev/null +++ b/popdocs/sessions/2026-02-06.md @@ -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 등) + - 데이터 바인딩 연결 + - 워크플로우 연동