diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 7fe11270..bf2878a5 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -23,8 +23,11 @@ import { createEmptyPopLayoutV5, GAP_PRESETS, GRID_BREAKPOINTS, + BLOCK_GAP, + BLOCK_PADDING, detectGridMode, } from "@/components/pop/designer/types/pop-layout"; +import { convertV5LayoutToV6 } from "@/components/pop/designer/utils/gridUtils"; // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) import "@/lib/registry/pop-components"; import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; @@ -117,8 +120,8 @@ function PopScreenViewPage() { const popLayout = await screenApi.getLayoutPop(screenId); if (popLayout && isV5Layout(popLayout)) { - // v5 레이아웃 로드 - setLayout(popLayout); + const v6Layout = convertV5LayoutToV6(popLayout); + setLayout(v6Layout); const componentCount = Object.keys(popLayout.components).length; console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); } else if (popLayout) { @@ -318,12 +321,8 @@ function PopScreenViewPage() { style={{ maxWidth: 1366 }} > {(() => { - // 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)); + const adjustedGap = BLOCK_GAP; + const adjustedPadding = BLOCK_PADDING; return ( (null); const canvasRef = useRef(null); - // 현재 뷰포트 해상도 + // V6: 뷰포트에서 동적 블록 칸 수 계산 const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; - const breakpoint = GRID_BREAKPOINTS[currentMode]; + const dynamicColumns = getBlockColumns(customWidth); + const breakpoint = { + ...GRID_BREAKPOINTS[currentMode], + columns: dynamicColumns, + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `${dynamicColumns}칸 블록`, + }; - // Gap 프리셋 적용 + // V6: 블록 간격 고정 (프리셋 무관) 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)); + const adjustedGap = BLOCK_GAP; + const adjustedPadding = BLOCK_PADDING; // 숨김 컴포넌트 ID 목록 (activeLayout 기반) const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || []; @@ -805,7 +807,7 @@ export default function PopCanvas({ {/* 하단 정보 */}
- {breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px) + V6 블록 그리드 - {dynamicColumns}칸 (블록: {BLOCK_SIZE}px, 간격: {BLOCK_GAP}px)
Space + 드래그: 패닝 | Ctrl + 휠: 줌 diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 36241817..de131032 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -33,7 +33,7 @@ import { PopModalDefinition, PopDataConnection, } from "./types/pop-layout"; -import { getAllEffectivePositions } from "./utils/gridUtils"; +import { getAllEffectivePositions, convertV5LayoutToV6 } from "./utils/gridUtils"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { PopDesignerContext } from "./PopDesignerContext"; @@ -151,13 +151,12 @@ export default function PopDesigner({ const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { - // v5 레이아웃 로드 - // 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가 if (!loadedLayout.settings.gapPreset) { loadedLayout.settings.gapPreset = "medium"; } - setLayout(loadedLayout); - setHistory([loadedLayout]); + const v6Layout = convertV5LayoutToV6(loadedLayout); + setLayout(v6Layout); + setHistory([v6Layout]); setHistoryIndex(0); // 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지) @@ -605,9 +604,6 @@ export default function PopDesigner({ // ======================================== const handleHideComponent = useCallback((componentId: string) => { - // 12칸 모드에서는 숨기기 불가 - if (currentMode === "tablet_landscape") return; - const currentHidden = layout.overrides?.[currentMode]?.hidden || []; // 이미 숨겨져 있으면 무시 diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 32ff5e06..ec22426d 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -7,6 +7,8 @@ import { PopGridPosition, GridMode, GRID_BREAKPOINTS, + BLOCK_SIZE, + getBlockColumns, } from "../types/pop-layout"; import { Settings, @@ -378,7 +380,7 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate

- 높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px + 높이: {position.rowSpan * BLOCK_SIZE + (position.rowSpan - 1) * 2}px

@@ -470,10 +472,10 @@ interface VisibilityFormProps { function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { const modes: Array<{ key: GridMode; label: string }> = [ - { key: "tablet_landscape", label: "태블릿 가로 (12칸)" }, - { key: "tablet_portrait", label: "태블릿 세로 (8칸)" }, - { key: "mobile_landscape", label: "모바일 가로 (6칸)" }, - { key: "mobile_portrait", label: "모바일 세로 (4칸)" }, + { key: "tablet_landscape", label: `태블릿 가로 (${getBlockColumns(1024)}칸)` }, + { key: "tablet_portrait", label: `태블릿 세로 (${getBlockColumns(820)}칸)` }, + { key: "mobile_landscape", label: `모바일 가로 (${getBlockColumns(600)}칸)` }, + { key: "mobile_portrait", label: `모바일 세로 (${getBlockColumns(375)}칸)` }, ]; const handleVisibilityChange = (mode: GridMode, visible: boolean) => { diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index a9c7db6e..373bed9b 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -13,6 +13,10 @@ import { GridBreakpoint, detectGridMode, PopComponentType, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, } from "../types/pop-layout"; import { convertAndResolvePositions, @@ -107,18 +111,27 @@ export default function PopRenderer({ }: PopRendererProps) { const { gridConfig, components, overrides } = layout; - // 현재 모드 (자동 감지 또는 지정) + // V6: 뷰포트 너비에서 블록 칸 수 동적 계산 const mode = currentMode || detectGridMode(viewportWidth); - const breakpoint = GRID_BREAKPOINTS[mode]; + const columns = getBlockColumns(viewportWidth); - // Gap/Padding: 오버라이드 우선, 없으면 기본값 사용 - const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap; - const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding; + // V6: 블록 간격 고정 + const finalGap = overrideGap !== undefined ? overrideGap : BLOCK_GAP; + const finalPadding = overridePadding !== undefined ? overridePadding : BLOCK_PADDING; + + // 하위 호환: breakpoint 객체 (ResizeHandles 등에서 사용) + const breakpoint: GridBreakpoint = { + columns, + rowHeight: BLOCK_SIZE, + gap: finalGap, + padding: finalPadding, + label: `${columns}칸 블록`, + }; // 숨김 컴포넌트 ID 목록 const hiddenIds = overrides?.[mode]?.hidden || []; - // 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외) + // 동적 행 수 계산 const dynamicRowCount = useMemo(() => { const visibleComps = Object.values(components).filter( comp => !hiddenIds.includes(comp.id) @@ -131,19 +144,17 @@ export default function PopRenderer({ return Math.max(10, maxRowEnd + 3); }, [components, overrides, mode, hiddenIds]); - // CSS Grid 스타일 - // 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집) - // 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능) + // V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE const rowTemplate = isDesignMode - ? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)` - : `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`; + ? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)` + : `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`; const autoRowHeight = isDesignMode - ? `${breakpoint.rowHeight}px` - : `minmax(${breakpoint.rowHeight}px, auto)`; + ? `${BLOCK_SIZE}px` + : `minmax(${BLOCK_SIZE}px, auto)`; const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", - gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, + gridTemplateColumns: `repeat(${columns}, 1fr)`, gridTemplateRows: rowTemplate, gridAutoRows: autoRowHeight, gap: `${finalGap}px`, @@ -151,15 +162,15 @@ export default function PopRenderer({ minHeight: "100%", backgroundColor: "#ffffff", position: "relative", - }), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); + }), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); - // 그리드 가이드 셀 생성 (동적 행 수) + // 그리드 가이드 셀 생성 const gridCells = useMemo(() => { if (!isDesignMode || !showGridGuide) return []; const cells = []; for (let row = 1; row <= dynamicRowCount; row++) { - for (let col = 1; col <= breakpoint.columns; col++) { + for (let col = 1; col <= columns; col++) { cells.push({ id: `cell-${col}-${row}`, col, @@ -168,7 +179,7 @@ export default function PopRenderer({ } } return cells; - }, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]); + }, [isDesignMode, showGridGuide, columns, dynamicRowCount]); // visibility 체크 const isVisible = (comp: PopComponentDefinitionV5): boolean => { diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 9fb9a847..44e4c1c4 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -99,24 +99,39 @@ export interface PopLayoutMetadata { } // ======================================== -// v5 그리드 기반 레이아웃 +// v6 정사각형 블록 그리드 시스템 // ======================================== -// 핵심: CSS Grid로 정확한 위치 지정 -// - 열/행 좌표로 배치 (col, row) -// - 칸 단위 크기 (colSpan, rowSpan) -// - Material Design 브레이크포인트 기반 +// 핵심: 균일한 정사각형 블록 (24px x 24px) +// - 열/행 좌표로 배치 (col, row) - 블록 단위 +// - 뷰포트 너비에 따라 칸 수 동적 계산 +// - 단일 좌표계 (모드별 변환 불필요) /** - * 그리드 모드 (4가지) + * V6 블록 상수 + */ +export const BLOCK_SIZE = 24; // 블록 크기 (px, 정사각형) +export const BLOCK_GAP = 2; // 블록 간격 (px) +export const BLOCK_PADDING = 8; // 캔버스 패딩 (px) + +/** + * 뷰포트 너비에서 블록 칸 수 계산 + */ +export function getBlockColumns(viewportWidth: number): number { + const available = viewportWidth - BLOCK_PADDING * 2; + return Math.max(1, Math.floor((available + BLOCK_GAP) / (BLOCK_SIZE + BLOCK_GAP))); +} + +/** + * 그리드 모드 (하위 호환용 - V6에서는 뷰포트 프리셋 라벨로만 사용) */ export type GridMode = - | "mobile_portrait" // 4칸 - | "mobile_landscape" // 6칸 - | "tablet_portrait" // 8칸 - | "tablet_landscape"; // 12칸 (기본) + | "mobile_portrait" + | "mobile_landscape" + | "tablet_portrait" + | "tablet_landscape"; /** - * 그리드 브레이크포인트 설정 + * 그리드 브레이크포인트 설정 (하위 호환용) */ export interface GridBreakpoint { minWidth?: number; @@ -129,50 +144,43 @@ export interface GridBreakpoint { } /** - * 브레이크포인트 상수 - * 업계 표준 (768px, 1024px) + 실제 기기 커버리지 기반 + * V6 브레이크포인트 (블록 기반 동적 칸 수) + * columns는 각 뷰포트 너비에서의 블록 수 */ export const GRID_BREAKPOINTS: Record = { - // 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra) mobile_portrait: { maxWidth: 479, - columns: 4, - rowHeight: 40, - gap: 8, - padding: 12, - label: "모바일 세로 (4칸)", + columns: getBlockColumns(375), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `모바일 세로 (${getBlockColumns(375)}칸)`, }, - - // 스마트폰 가로 + 소형 태블릿 mobile_landscape: { minWidth: 480, maxWidth: 767, - columns: 6, - rowHeight: 44, - gap: 8, - padding: 16, - label: "모바일 가로 (6칸)", + columns: getBlockColumns(600), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `모바일 가로 (${getBlockColumns(600)}칸)`, }, - - // 태블릿 세로 (iPad Mini ~ iPad Pro) tablet_portrait: { minWidth: 768, maxWidth: 1023, - columns: 8, - rowHeight: 48, - gap: 12, - padding: 16, - label: "태블릿 세로 (8칸)", + columns: getBlockColumns(820), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `태블릿 세로 (${getBlockColumns(820)}칸)`, }, - - // 태블릿 가로 + 데스크톱 (기본) tablet_landscape: { minWidth: 1024, - columns: 12, - rowHeight: 48, - gap: 16, - padding: 24, - label: "태블릿 가로 (12칸)", + columns: getBlockColumns(1024), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `태블릿 가로 (${getBlockColumns(1024)}칸)`, }, } as const; @@ -183,7 +191,6 @@ export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape"; /** * 뷰포트 너비로 모드 감지 - * GRID_BREAKPOINTS와 일치하는 브레이크포인트 사용 */ export function detectGridMode(viewportWidth: number): GridMode { if (viewportWidth < 480) return "mobile_portrait"; @@ -225,17 +232,17 @@ export interface PopLayoutDataV5 { } /** - * 그리드 설정 + * 그리드 설정 (V6: 블록 단위) */ export interface PopGridConfig { - // 행 높이 (px) - 1행의 기본 높이 - rowHeight: number; // 기본 48px + // 행 높이 = 블록 크기 (px) + rowHeight: number; // V6 기본 24px (= BLOCK_SIZE) // 간격 (px) - gap: number; // 기본 8px + gap: number; // V6 기본 2px (= BLOCK_GAP) // 패딩 (px) - padding: number; // 기본 16px + padding: number; // V6 기본 8px (= BLOCK_PADDING) } /** @@ -274,7 +281,7 @@ export interface PopComponentDefinitionV5 { } /** - * Gap 프리셋 타입 + * Gap 프리셋 타입 (V6: 단일 간격이므로 medium만 유효, 하위 호환용 유지) */ export type GapPreset = "narrow" | "medium" | "wide"; @@ -287,12 +294,12 @@ export interface GapPresetConfig { } /** - * Gap 프리셋 상수 + * Gap 프리셋 상수 (V6: 모두 동일 - 블록 간격 고정) */ export const GAP_PRESETS: Record = { - narrow: { multiplier: 0.5, label: "좁게" }, - medium: { multiplier: 1.0, label: "보통" }, - wide: { multiplier: 1.5, label: "넓게" }, + narrow: { multiplier: 1.0, label: "기본" }, + medium: { multiplier: 1.0, label: "기본" }, + wide: { multiplier: 1.0, label: "기본" }, }; /** @@ -330,9 +337,9 @@ export interface PopModeOverrideV5 { export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ version: "pop-5.0", gridConfig: { - rowHeight: 48, - gap: 8, - padding: 16, + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, }, components: {}, dataFlow: { connections: [] }, @@ -351,22 +358,27 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { }; /** - * 컴포넌트 타입별 기본 크기 (칸 단위) + * 컴포넌트 타입별 기본 크기 (블록 단위, V6) + * + * 소형 (2x2) : 최소 단위. 아이콘, 프로필, 스캐너 등 단일 요소 + * 중형 (8x4) : 검색, 버튼, 텍스트 등 한 줄 입력/표시 + * 대형 (8x6) : 샘플, 상태바, 필드 등 여러 줄 컨텐츠 + * 초대형 (19x8~) : 카드, 리스트, 대시보드 등 메인 영역 */ export const DEFAULT_COMPONENT_GRID_SIZE: Record = { - "pop-sample": { colSpan: 2, rowSpan: 1 }, - "pop-text": { colSpan: 3, rowSpan: 1 }, - "pop-icon": { colSpan: 1, rowSpan: 2 }, - "pop-dashboard": { colSpan: 6, rowSpan: 3 }, - "pop-card-list": { colSpan: 4, rowSpan: 3 }, - "pop-card-list-v2": { colSpan: 4, rowSpan: 3 }, - "pop-button": { colSpan: 2, rowSpan: 1 }, - "pop-string-list": { colSpan: 4, rowSpan: 3 }, - "pop-search": { colSpan: 2, rowSpan: 1 }, - "pop-status-bar": { colSpan: 6, rowSpan: 1 }, - "pop-field": { colSpan: 6, rowSpan: 2 }, - "pop-scanner": { colSpan: 1, rowSpan: 1 }, - "pop-profile": { colSpan: 1, rowSpan: 1 }, + "pop-sample": { colSpan: 8, rowSpan: 6 }, + "pop-text": { colSpan: 8, rowSpan: 4 }, + "pop-icon": { colSpan: 2, rowSpan: 2 }, + "pop-dashboard": { colSpan: 19, rowSpan: 10 }, + "pop-card-list": { colSpan: 19, rowSpan: 10 }, + "pop-card-list-v2": { colSpan: 19, rowSpan: 10 }, + "pop-button": { colSpan: 8, rowSpan: 4 }, + "pop-string-list": { colSpan: 19, rowSpan: 10 }, + "pop-search": { colSpan: 8, rowSpan: 4 }, + "pop-status-bar": { colSpan: 19, rowSpan: 4 }, + "pop-field": { colSpan: 19, rowSpan: 6 }, + "pop-scanner": { colSpan: 2, rowSpan: 2 }, + "pop-profile": { colSpan: 2, rowSpan: 2 }, }; /** diff --git a/frontend/components/pop/designer/utils/gridUtils.ts b/frontend/components/pop/designer/utils/gridUtils.ts index 308ce730..e5078f64 100644 --- a/frontend/components/pop/designer/utils/gridUtils.ts +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -6,196 +6,148 @@ import { GapPreset, GAP_PRESETS, PopLayoutDataV5, - PopComponentDefinitionV5, + PopComponentDefinitionV5, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, } from "../types/pop-layout"; // ======================================== -// Gap/Padding 조정 +// Gap/Padding 조정 (V6: 블록 간격 고정이므로 항상 원본 반환) // ======================================== -/** - * 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 - }; + return { ...base }; } // ======================================== -// 그리드 위치 변환 +// 그리드 위치 변환 (V6: 단일 좌표계이므로 변환 불필요) // ======================================== /** - * 12칸 기준 위치를 다른 모드로 변환 + * V6: 단일 좌표계이므로 변환 없이 원본 반환 + * @deprecated V6에서는 좌표 변환이 불필요합니다 */ export function convertPositionToMode( position: PopGridPosition, targetMode: GridMode ): PopGridPosition { - const sourceColumns = 12; - const targetColumns = GRID_BREAKPOINTS[targetMode].columns; - - // 같은 칸 수면 그대로 반환 - if (sourceColumns === targetColumns) { - return position; - } - - const ratio = targetColumns / sourceColumns; - - // 열 위치 변환 - let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); - let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); - - // 범위 초과 방지 - if (newCol > targetColumns) { - newCol = 1; - } - if (newCol + newColSpan - 1 > targetColumns) { - newColSpan = targetColumns - newCol + 1; - } - - return { - col: newCol, - row: position.row, - colSpan: Math.max(1, newColSpan), - rowSpan: position.rowSpan, - }; + return position; } /** - * 여러 컴포넌트를 모드별로 변환하고 겹침 해결 - * - * v5.1 자동 줄바꿈: - * - 원본 col > targetColumns인 컴포넌트는 자동으로 맨 아래에 배치 - * - 정보 손실 방지: 모든 컴포넌트가 그리드 안에 배치됨 + * V6 행 그룹 리플로우 (방식 F) + * + * 원리: CSS Flexbox wrap과 동일. + * 1. 같은 행의 컴포넌트를 한 묶음으로 처리 + * 2. 최소 2x2칸 보장 (터치 가능한 최소 크기) + * 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음) + * 4. 설계 너비의 50% 이상 → 전체 너비 확장 + * 5. 리플로우 후 겹침 해결 (resolveOverlaps) */ export function convertAndResolvePositions( components: Array<{ id: string; position: PopGridPosition }>, targetMode: GridMode ): Array<{ id: string; position: PopGridPosition }> { - // 엣지 케이스: 빈 배열 - if (components.length === 0) { - return []; - } + if (components.length === 0) return []; const targetColumns = GRID_BREAKPOINTS[targetMode].columns; + const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns; - // 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존) - const converted = components.map(comp => ({ - id: comp.id, - position: convertPositionToMode(comp.position, targetMode), - originalCol: comp.position.col, // 원본 col 보존 - })); + if (targetColumns >= designColumns) { + return components.map(c => ({ id: c.id, position: { ...c.position } })); + } - // 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리 - const normalComponents = converted.filter(c => c.originalCol <= targetColumns); - const overflowComponents = converted.filter(c => c.originalCol > targetColumns); + const ratio = targetColumns / designColumns; + const MIN_COL_SPAN = 2; + const MIN_ROW_SPAN = 2; - // 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, - }; + // 1. 원본 row 기준 그룹핑 + const rowGroups: Record> = {}; + components.forEach(comp => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(comp); }); - // 5단계: 정상 + 줄바꿈 컴포넌트 병합 - const adjusted = [ - ...normalComponents.map(c => ({ id: c.id, position: c.position })), - ...wrappedComponents, - ]; + const placed: Array<{ id: string; position: PopGridPosition }> = []; + let outputRow = 1; - // 6단계: 겹침 해결 (아래로 밀기) - return resolveOverlaps(adjusted, targetColumns); + // 2. 각 행 그룹을 순서대로 처리 + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); + + for (const rowKey of sortedRows) { + const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col); + let currentCol = 1; + let maxRowSpanInLine = 0; + + for (const comp of group) { + const pos = comp.position; + const isMainContent = pos.colSpan >= designColumns * 0.5; + + let scaledSpan = isMainContent + ? targetColumns + : Math.max(MIN_COL_SPAN, Math.round(pos.colSpan * ratio)); + scaledSpan = Math.min(scaledSpan, targetColumns); + + const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan); + + // 현재 줄에 안 들어가면 줄바꿈 + if (currentCol + scaledSpan - 1 > targetColumns) { + outputRow += Math.max(1, maxRowSpanInLine); + currentCol = 1; + maxRowSpanInLine = 0; + } + + placed.push({ + id: comp.id, + position: { + col: currentCol, + row: outputRow, + colSpan: scaledSpan, + rowSpan: scaledRowSpan, + }, + }); + + maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan); + currentCol += scaledSpan; + } + + outputRow += Math.max(1, maxRowSpanInLine); + } + + // 3. 겹침 해결 (행 그룹 간 rowSpan 충돌 처리) + return resolveOverlaps(placed, targetColumns); } // ======================================== -// 검토 필요 판별 +// 검토 필요 판별 (V6: 자동 줄바꿈이므로 검토 필요 없음) // ======================================== /** - * 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인 - * - * v5.1 검토 필요 기준: - * - 12칸 모드(기본 모드)가 아님 - * - 해당 모드에서 오버라이드가 없음 (아직 편집 안 함) - * - * @param currentMode 현재 그리드 모드 - * @param hasOverride 해당 모드에서 오버라이드 존재 여부 - * @returns true = 검토 필요, false = 검토 완료 또는 불필요 + * V6: 단일 좌표계 + 자동 줄바꿈이므로 검토 필요 없음 + * 항상 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; + return false; } /** - * @deprecated v5.1부터 needsReview() 사용 권장 - * - * 기존 isOutOfBounds는 "화면 밖" 개념이었으나, - * v5.1 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 배치됩니다. - * 대신 needsReview()로 "검토 필요" 여부를 판별하세요. + * @deprecated V6에서는 자동 줄바꿈이므로 화면 밖 개념 없음 */ export function isOutOfBounds( originalPosition: PopGridPosition, currentMode: GridMode, overridePosition?: PopGridPosition | null ): boolean { - const targetColumns = GRID_BREAKPOINTS[currentMode].columns; - - // 12칸 모드면 초과 불가 - if (targetColumns === 12) { - return false; - } - - // 오버라이드가 있으면 오버라이드 위치로 판단 - if (overridePosition) { - return overridePosition.col > targetColumns; - } - - // 오버라이드 없으면 원본 col로 판단 - return originalPosition.col > targetColumns; + return false; } // ======================================== @@ -269,12 +221,8 @@ export function resolveOverlaps( // ======================================== /** - * 마우스 좌표 → 그리드 좌표 변환 - * - * CSS Grid 계산 방식: - * - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1) - * - 각 칸 너비 = 사용 가능 너비 / columns - * - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap) + * V6: 마우스 좌표 → 블록 그리드 좌표 변환 + * 블록 크기가 고정(BLOCK_SIZE)이므로 계산이 단순함 */ export function mouseToGridPosition( mouseX: number, @@ -285,28 +233,19 @@ export function mouseToGridPosition( gap: number, padding: number ): { col: number; row: number } { - // 캔버스 내 상대 위치 (패딩 영역 포함) const relX = mouseX - canvasRect.left - padding; const relY = mouseY - canvasRect.top - padding; - // CSS Grid 1fr 계산과 동일하게 - // 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap) - const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1); - const colWidth = availableWidth / columns; + const cellStride = BLOCK_SIZE + gap; - // 각 셀의 실제 간격 (셀 너비 + gap) - const cellStride = colWidth + gap; - - // 그리드 좌표 계산 (1부터 시작) - // relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음 const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); - const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); + const row = Math.max(1, Math.floor(relY / cellStride) + 1); return { col, row }; } /** - * 그리드 좌표 → 픽셀 좌표 변환 + * V6: 블록 그리드 좌표 → 픽셀 좌표 변환 */ export function gridToPixelPosition( col: number, @@ -319,14 +258,13 @@ export function gridToPixelPosition( gap: number, padding: number ): { x: number; y: number; width: number; height: number } { - const totalGap = gap * (columns - 1); - const colWidth = (canvasWidth - padding * 2 - totalGap) / columns; + const cellStride = BLOCK_SIZE + gap; return { - x: padding + (col - 1) * (colWidth + gap), - y: padding + (row - 1) * (rowHeight + gap), - width: colWidth * colSpan + gap * (colSpan - 1), - height: rowHeight * rowSpan + gap * (rowSpan - 1), + x: padding + (col - 1) * cellStride, + y: padding + (row - 1) * cellStride, + width: BLOCK_SIZE * colSpan + gap * (colSpan - 1), + height: BLOCK_SIZE * rowSpan + gap * (rowSpan - 1), }; } @@ -560,3 +498,126 @@ export function getAllEffectivePositions( return result; } + +// ======================================== +// V5 → V6 런타임 변환 (DB 미수정, 로드 시 변환) +// ======================================== + +const V5_BASE_COLUMNS = 12; +const V5_BASE_ROW_HEIGHT = 48; +const V5_BASE_GAP = 16; +const V5_DESIGN_WIDTH = 1024; + +/** + * V5 레이아웃 판별: gridConfig.rowHeight가 V5 기본값(48)이고 + * 좌표가 12칸 체계인 경우만 V5로 판정 + */ +function isV5GridConfig(layout: PopLayoutDataV5): boolean { + if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false; + + const maxCol = Object.values(layout.components).reduce((max, comp) => { + const end = comp.position.col + comp.position.colSpan - 1; + return Math.max(max, end); + }, 0); + + return maxCol <= V5_BASE_COLUMNS; +} + +function convertV5PositionToV6( + pos: PopGridPosition, + v6DesignColumns: number, +): PopGridPosition { + const colRatio = v6DesignColumns / V5_BASE_COLUMNS; + const rowRatio = (V5_BASE_ROW_HEIGHT + V5_BASE_GAP) / (BLOCK_SIZE + BLOCK_GAP); + + const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1); + let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio)); + const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio)); + + if (newCol + newColSpan - 1 > v6DesignColumns) { + newColSpan = v6DesignColumns - newCol + 1; + } + + return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan }; +} + +/** + * V5 레이아웃을 V6 블록 좌표로 런타임 변환 + * - 기본 모드(tablet_landscape) 좌표를 블록 단위로 변환 + * - 모드별 overrides 폐기 (자동 줄바꿈으로 대체) + * - DB 데이터는 건드리지 않음 (메모리에서만 변환) + */ +export function convertV5LayoutToV6(layout: PopLayoutDataV5): PopLayoutDataV5 { + // V5 오버라이드는 V6에서 무효 (4/6/8칸용 좌표가 13/22/31칸에 맞지 않음) + // 좌표 변환 필요 여부와 무관하게 항상 제거 + if (!isV5GridConfig(layout)) { + return { + ...layout, + gridConfig: { + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + }, + overrides: undefined, + }; + } + + const v6Columns = getBlockColumns(V5_DESIGN_WIDTH); + + const rowGroups: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(id); + }); + + const convertedPositions: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + convertedPositions[id] = convertV5PositionToV6(comp.position, v6Columns); + }); + + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); + const rowMapping: Record = {}; + let v6Row = 1; + for (const v5Row of sortedRows) { + rowMapping[v5Row] = v6Row; + const maxSpan = Math.max( + ...rowGroups[v5Row].map(id => convertedPositions[id].rowSpan) + ); + v6Row += maxSpan; + } + + const newComponents = { ...layout.components }; + Object.entries(newComponents).forEach(([id, comp]) => { + const converted = convertedPositions[id]; + const mappedRow = rowMapping[comp.position.row] ?? converted.row; + newComponents[id] = { + ...comp, + position: { ...converted, row: mappedRow }, + }; + }); + + const newModals = layout.modals?.map(modal => { + const modalComps = { ...modal.components }; + Object.entries(modalComps).forEach(([id, comp]) => { + modalComps[id] = { + ...comp, + position: convertV5PositionToV6(comp.position, v6Columns), + }; + }); + return { + ...modal, + gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, + components: modalComps, + overrides: undefined, + }; + }); + + return { + ...layout, + gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, + components: newComponents, + overrides: undefined, + modals: newModals, + }; +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index 1c351cf2..f5d06036 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -295,8 +295,8 @@ function BasicSettingsTab({ const recommendation = useMemo(() => { if (!currentMode) return null; const cols = GRID_BREAKPOINTS[currentMode].columns; - if (cols >= 8) return { rows: 3, cols: 2 }; - if (cols >= 6) return { rows: 3, cols: 1 }; + if (cols >= 25) return { rows: 3, cols: 2 }; + if (cols >= 18) return { rows: 3, cols: 1 }; return { rows: 2, cols: 1 }; }, [currentMode]);