"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; /** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */ onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; /** Gap 오버라이드 (Gap 프리셋 적용된 값) */ overrideGap?: number; /** Padding 오버라이드 (Gap 프리셋 적용된 값) */ overridePadding?: number; /** 추가 className */ className?: string; /** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */ currentScreenId?: number; /** 대시보드 페이지 미리보기 인덱스 */ previewPageIndex?: number; } // ======================================== // 컴포넌트 타입별 라벨 // ======================================== const COMPONENT_TYPE_LABELS: Record = { "pop-sample": "샘플", "pop-text": "텍스트", "pop-icon": "아이콘", "pop-dashboard": "대시보드", "pop-card-list": "카드 목록", "pop-button": "버튼", "pop-string-list": "리스트 목록", "pop-search": "검색", }; // ======================================== // PopRenderer: v5 그리드 렌더러 // ======================================== export default function PopRenderer({ layout, viewportWidth, currentMode, isDesignMode = false, showGridGuide = true, selectedComponentId, onComponentClick, onBackgroundClick, onComponentMove, onComponentResize, onComponentResizeEnd, onRequestResize, overrideGap, overridePadding, className, currentScreenId, previewPageIndex, }: 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 스타일 // 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집) // 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능) const rowTemplate = isDesignMode ? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)` : `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`; const autoRowHeight = isDesignMode ? `${breakpoint.rowHeight}px` : `minmax(${breakpoint.rowHeight}px, auto)`; const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, gridTemplateRows: rowTemplate, gridAutoRows: autoRowHeight, gap: `${finalGap}px`, padding: `${finalPadding}px`, minHeight: "100%", backgroundColor: "#ffffff", position: "relative", }), [breakpoint, 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++) { 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 ( ); } // 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용) 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; onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; previewPageIndex?: number; } function DraggableComponent({ component, position, positionStyle, isSelected, isDesignMode, breakpoint, viewportWidth, allEffectivePositions, effectiveGap, effectivePadding, onComponentClick, onComponentMove, onComponentResize, onComponentResizeEnd, onRequestResize, previewPageIndex, }: 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; previewPageIndex?: number; onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; /** 화면 ID (이벤트 버스/액션 실행용) */ screenId?: string; } function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, onRequestResize, screenId }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // PopComponentRegistry에서 등록된 컴포넌트 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const PreviewComponent = registeredComp?.preview; // 디자인 모드: 실제 컴포넌트 또는 미리보기 표시 (헤더 없음 - 뷰어와 동일하게) if (isDesignMode) { const ActualComp = registeredComp?.component; // 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등) if (ActualComp) { // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용 // CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용 const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list"; return (
); } // 미등록: preview 컴포넌트 또는 기본 플레이스홀더 return (
{PreviewComponent ? ( ) : ( {typeLabel} )}
); } // 실제 모드: 컴포넌트 렌더링 (뷰어 모드에서도 리사이즈 지원) return renderActualComponent(component, effectivePosition, onRequestResize, screenId); } // ======================================== // 실제 컴포넌트 렌더링 (뷰어 모드) // ======================================== function renderActualComponent( component: PopComponentDefinitionV5, effectivePosition?: PopGridPosition, onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void, screenId?: string, ): React.ReactNode { // 레지스트리에서 등록된 실제 컴포넌트 조회 const registeredComp = PopComponentRegistry.getComponent(component.type); const ActualComp = registeredComp?.component; if (ActualComp) { return (
); } // 미등록 컴포넌트: 플레이스홀더 (fallback) const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; return (
{component.label || typeLabel}
); }