"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, isOutOfBounds, isOverlapping, getAllEffectivePositions, } from "../utils/gridUtils"; // ======================================== // 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; /** 추가 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, className, }: PopRendererProps) { const { gridConfig, components, overrides } = layout; // 현재 모드 (자동 감지 또는 지정) const mode = currentMode || detectGridMode(viewportWidth); const breakpoint = GRID_BREAKPOINTS[mode]; // CSS Grid 스타일 const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, gridAutoRows: `${breakpoint.rowHeight}px`, gap: `${breakpoint.gap}px`, padding: `${breakpoint.padding}px`, minHeight: "100%", backgroundColor: "#ffffff", position: "relative", }), [breakpoint]); // 그리드 가이드 셀 생성 const gridCells = useMemo(() => { if (!isDesignMode || !showGridGuide) return []; const cells = []; const rowCount = 20; // 충분한 행 수 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; }, [isDesignMode, showGridGuide, breakpoint.columns]); // 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로 위에 표시) */} {/* 디자인 모드에서는 초과 컴포넌트를 그리드에서 제외 (오른쪽 별도 영역에 표시) */} {Object.values(components).map((comp) => { // visibility 체크 if (!isVisible(comp)) return null; // 오버라이드 숨김 체크 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; return ( ); })}
); } // ======================================== // 드래그 가능한 컴포넌트 래퍼 // ======================================== interface DraggableComponentProps { component: PopComponentDefinitionV5; position: PopGridPosition; positionStyle: React.CSSProperties; isSelected: boolean; isDesignMode: boolean; isOutOfBounds: boolean; breakpoint: GridBreakpoint; viewportWidth: number; allEffectivePositions: Map; 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, isOutOfBounds, breakpoint, viewportWidth, allEffectivePositions, 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); }} title={ isDesignMode && isOutOfBounds ? `이 컴포넌트는 ${breakpoint.label}에서 표시되지 않습니다` : undefined } > {/* 리사이즈 핸들 (선택된 컴포넌트만, 초과 아닐 때만) */} {isDesignMode && isSelected && !isOutOfBounds && onComponentResize && ( )}
); } // ======================================== // 리사이즈 핸들 // ======================================== interface ResizeHandlesProps { component: PopComponentDefinitionV5; position: PopGridPosition; breakpoint: GridBreakpoint; viewportWidth: number; allEffectivePositions: Map; onResize: (componentId: string, newPosition: PopGridPosition) => void; onResizeEnd?: (componentId: string) => void; } function ResizeHandles({ component, position, breakpoint, viewportWidth, allEffectivePositions, 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*(칸수-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 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; isOutOfBounds: boolean; } function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, isOutOfBounds }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // 디자인 모드: 플레이스홀더 표시 if (isDesignMode) { return (
{/* 헤더 */}
{component.label || typeLabel} {/* 초과 표시 */} {isOutOfBounds && ( )}
{/* 내용 */}
{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}
); }