"use client"; import React, { useMemo } from "react"; import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { DND_ITEM_TYPES } from "../constants"; import { PopLayoutDataV5, PopComponentDefinitionV5, PopGridPosition, GridMode, GRID_BREAKPOINTS, GridBreakpoint, detectGridMode, PopComponentType, } from "../types/pop-layout"; import { convertAndResolvePositions, isOverlapping, getAllEffectivePositions, } from "../utils/gridUtils"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; // ======================================== // Props // ======================================== interface PopRendererProps { /** v5 레이아웃 데이터 */ layout: PopLayoutDataV5; /** 현재 뷰포트 너비 */ viewportWidth: number; /** 현재 모드 (자동 감지 또는 수동 지정) */ currentMode?: GridMode; /** 디자인 모드 여부 */ isDesignMode?: boolean; /** 그리드 가이드 표시 여부 */ showGridGuide?: boolean; /** 선택된 컴포넌트 ID */ selectedComponentId?: string | null; /** 컴포넌트 클릭 */ onComponentClick?: (componentId: string) => void; /** 배경 클릭 */ onBackgroundClick?: () => void; /** 컴포넌트 이동 */ onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; /** 컴포넌트 크기 조정 */ onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; /** 컴포넌트 크기 조정 완료 (히스토리 저장용) */ onComponentResizeEnd?: (componentId: string) => void; /** Gap 오버라이드 (Gap 프리셋 적용된 값) */ overrideGap?: number; /** Padding 오버라이드 (Gap 프리셋 적용된 값) */ overridePadding?: number; /** 추가 className */ className?: string; } // ======================================== // 컴포넌트 타입별 라벨 // ======================================== const COMPONENT_TYPE_LABELS: Record = { "pop-sample": "샘플", }; // ======================================== // PopRenderer: v5 그리드 렌더러 // ======================================== export default function PopRenderer({ layout, viewportWidth, currentMode, isDesignMode = false, showGridGuide = true, selectedComponentId, onComponentClick, onBackgroundClick, onComponentMove, onComponentResize, onComponentResizeEnd, overrideGap, overridePadding, className, }: PopRendererProps) { const { gridConfig, components, overrides } = layout; // 현재 모드 (자동 감지 또는 지정) const mode = currentMode || detectGridMode(viewportWidth); const breakpoint = GRID_BREAKPOINTS[mode]; // 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: `${finalGap}px`, padding: `${finalPadding}px`, minHeight: "100%", backgroundColor: "#ffffff", position: "relative", }), [breakpoint, finalGap, finalPadding, dynamicRowCount]); // 그리드 가이드 셀 생성 (동적 행 수) const gridCells = useMemo(() => { if (!isDesignMode || !showGridGuide) return []; const cells = []; for (let row = 1; row <= dynamicRowCount; row++) { for (let col = 1; col <= breakpoint.columns; col++) { cells.push({ id: `cell-${col}-${row}`, col, row }); } } return cells; }, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]); // visibility 체크 const isVisible = (comp: PopComponentDefinitionV5): boolean => { if (!comp.visibility) return true; const modeVisibility = comp.visibility[mode]; return modeVisibility !== false; }; // 자동 재배치된 위치 계산 (오버라이드 없을 때) const autoResolvedPositions = useMemo(() => { const componentsArray = Object.entries(components).map(([id, comp]) => ({ id, position: comp.position, })); return convertAndResolvePositions(componentsArray, mode); }, [components, mode]); // 위치 변환 (12칸 기준 → 현재 모드 칸 수) const convertPosition = (position: PopGridPosition): React.CSSProperties => { return { gridColumn: `${position.col} / span ${position.colSpan}`, gridRow: `${position.row} / span ${position.rowSpan}`, }; }; // 오버라이드 적용 또는 자동 재배치 const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { // 1순위: 오버라이드가 있으면 사용 const override = overrides?.[mode]?.positions?.[comp.id]; if (override) { return { ...comp.position, ...override }; } // 2순위: 자동 재배치된 위치 사용 const autoResolved = autoResolvedPositions.find(p => p.id === comp.id); if (autoResolved) { return autoResolved.position; } // 3순위: 원본 위치 (12칸 모드) return comp.position; }; // 오버라이드 숨김 체크 const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => { return overrides?.[mode]?.hidden?.includes(comp.id) ?? false; }; // 모든 컴포넌트의 유효 위치 계산 (리사이즈 겹침 검사용) const effectivePositionsMap = useMemo(() => getAllEffectivePositions(layout, mode), [layout, mode] ); return (
{ if (e.target === e.currentTarget) { onBackgroundClick?.(); } }} > {/* 그리드 가이드 셀 (실제 DOM) */} {gridCells.map(cell => (
))} {/* 컴포넌트 렌더링 (z-index로 위에 표시) */} {/* v5.1: 자동 줄바꿈으로 모든 컴포넌트가 그리드 안에 배치됨 */} {Object.values(components).map((comp) => { // visibility 체크 if (!isVisible(comp)) return null; // 오버라이드 숨김 체크 if (isHiddenByOverride(comp)) return null; const position = getEffectivePosition(comp); const positionStyle = convertPosition(position); const isSelected = selectedComponentId === comp.id; // 디자인 모드에서는 드래그 가능한 컴포넌트, 뷰어 모드에서는 일반 컴포넌트 if (isDesignMode) { return ( ); } // 뷰어 모드: 드래그 없는 일반 렌더링 return (
); })}
); } // ======================================== // 드래그 가능한 컴포넌트 래퍼 // ======================================== interface DraggableComponentProps { component: PopComponentDefinitionV5; position: PopGridPosition; positionStyle: React.CSSProperties; isSelected: boolean; isDesignMode: 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; onComponentResizeEnd?: (componentId: string) => void; } function DraggableComponent({ component, position, positionStyle, isSelected, isDesignMode, breakpoint, viewportWidth, allEffectivePositions, effectiveGap, effectivePadding, onComponentClick, onComponentMove, onComponentResize, onComponentResizeEnd, }: DraggableComponentProps) { const [{ isDragging }, drag] = useDrag( () => ({ type: DND_ITEM_TYPES.MOVE_COMPONENT, item: { componentId: component.id, originalPosition: position }, canDrag: isDesignMode, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }), [component.id, position, isDesignMode] ); return (
{ e.stopPropagation(); onComponentClick?.(component.id); }} > {/* 리사이즈 핸들 (선택된 컴포넌트만) */} {isDesignMode && isSelected && onComponentResize && ( )}
); } // ======================================== // 리사이즈 핸들 // ======================================== interface ResizeHandlesProps { component: PopComponentDefinitionV5; position: PopGridPosition; breakpoint: GridBreakpoint; viewportWidth: number; allEffectivePositions: Map; effectiveGap: number; effectivePadding: number; onResize: (componentId: string, newPosition: PopGridPosition) => void; onResizeEnd?: (componentId: string) => void; } function ResizeHandles({ component, position, breakpoint, viewportWidth, allEffectivePositions, effectiveGap, effectivePadding, onResize, onResizeEnd, }: ResizeHandlesProps) { const handleMouseDown = (direction: 'right' | 'bottom' | 'corner') => (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); const startX = e.clientX; const startY = e.clientY; const startColSpan = position.colSpan; const startRowSpan = position.rowSpan; // 그리드 셀 크기 동적 계산 (Gap 프리셋 적용된 값 사용) // 사용 가능한 너비 = 뷰포트 너비 - 양쪽 패딩 - gap*(칸수-1) 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; const deltaY = e.clientY - startY; let newColSpan = startColSpan; let newRowSpan = startRowSpan; if (direction === 'right' || direction === 'corner') { const colDelta = Math.round(deltaX / cellWidth); newColSpan = Math.max(1, startColSpan + colDelta); // 최대 칸 수 제한 newColSpan = Math.min(newColSpan, breakpoint.columns - position.col + 1); } if (direction === 'bottom' || direction === 'corner') { const rowDelta = Math.round(deltaY / cellHeight); newRowSpan = Math.max(1, startRowSpan + rowDelta); } // 변경사항이 있으면 업데이트 if (newColSpan !== position.colSpan || newRowSpan !== position.rowSpan) { const newPosition: PopGridPosition = { ...position, colSpan: newColSpan, rowSpan: newRowSpan, }; // 유효 위치 기반 겹침 검사 (다른 컴포넌트와) const hasOverlap = Array.from(allEffectivePositions.entries()).some( ([id, pos]) => { if (id === component.id) return false; // 자기 자신 제외 return isOverlapping(newPosition, pos); } ); // 겹치지 않을 때만 리사이즈 적용 if (!hasOverlap) { onResize(component.id, newPosition); } } }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); // 리사이즈 완료 알림 (히스토리 저장용) onResizeEnd?.(component.id); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; return ( <> {/* 오른쪽 핸들 (가로 크기) */}
{/* 아래쪽 핸들 (세로 크기) */}
{/* 오른쪽 아래 모서리 (가로+세로) */}
); } // ======================================== // 컴포넌트 내용 렌더링 // ======================================== interface ComponentContentProps { component: PopComponentDefinitionV5; effectivePosition: PopGridPosition; isDesignMode: boolean; isSelected: boolean; } function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // PopComponentRegistry에서 등록된 컴포넌트 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const PreviewComponent = registeredComp?.preview; // 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시 if (isDesignMode) { return (
{/* 헤더 */}
{component.label || typeLabel}
{/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */}
{PreviewComponent ? ( ) : ( {typeLabel} )}
{/* 위치 정보 표시 (유효 위치 사용) */}
{effectivePosition.col},{effectivePosition.row} ({effectivePosition.colSpan}×{effectivePosition.rowSpan})
); } // 실제 모드: 컴포넌트 렌더링 return renderActualComponent(component); } // ======================================== // 실제 컴포넌트 렌더링 (뷰어 모드) // ======================================== function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode { const typeLabel = COMPONENT_TYPE_LABELS[component.type]; // 샘플 박스 렌더링 return (
{component.label || typeLabel}
); }