ERP-node/frontend/components/pop/designer/renderers/PopRenderer.tsx

634 lines
21 KiB
TypeScript
Raw Normal View History

"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<PopComponentType, string> = {
"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 (
<div
className={cn("relative min-h-full w-full", className)}
style={gridStyle}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 그리드 가이드 셀 (실제 DOM) */}
{gridCells.map(cell => (
<div
key={cell.id}
className="pointer-events-none border border-dashed border-blue-300/40"
style={{
gridColumn: cell.col,
gridRow: cell.row,
}}
/>
))}
{/* 컴포넌트 렌더링 (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 (
<DraggableComponent
key={comp.id}
component={comp}
position={position}
positionStyle={positionStyle}
isSelected={isSelected}
isDesignMode={isDesignMode}
breakpoint={breakpoint}
viewportWidth={viewportWidth}
allEffectivePositions={effectivePositionsMap}
effectiveGap={finalGap}
effectivePadding={finalPadding}
onComponentClick={onComponentClick}
onComponentMove={onComponentMove}
onComponentResize={onComponentResize}
onComponentResizeEnd={onComponentResizeEnd}
onRequestResize={onRequestResize}
previewPageIndex={previewPageIndex}
/>
);
}
// 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용)
return (
<div
key={comp.id}
className="relative overflow-hidden rounded-lg border-2 border-gray-200 bg-white transition-all z-10"
style={positionStyle}
>
<ComponentContent
component={comp}
effectivePosition={position}
isDesignMode={false}
isSelected={false}
onRequestResize={onRequestResize}
screenId={currentScreenId ? String(currentScreenId) : undefined}
/>
</div>
);
})}
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 래퍼
// ========================================
interface DraggableComponentProps {
component: PopComponentDefinitionV5;
position: PopGridPosition;
positionStyle: React.CSSProperties;
isSelected: boolean;
isDesignMode: boolean;
breakpoint: GridBreakpoint;
viewportWidth: number;
allEffectivePositions: Map<string, PopGridPosition>;
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 (
<div
ref={isDesignMode ? drag : null}
className={cn(
"relative rounded-lg border-2 transition-all overflow-hidden z-10 bg-white",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
isDesignMode && "cursor-move hover:border-gray-300 hover:shadow-sm",
isDragging && "opacity-50"
)}
style={positionStyle}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.(component.id);
}}
>
<ComponentContent
component={component}
effectivePosition={position}
isDesignMode={isDesignMode}
isSelected={isSelected}
previewPageIndex={previewPageIndex}
onRequestResize={onRequestResize}
screenId={undefined}
/>
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
{isDesignMode && isSelected && onComponentResize && (
<ResizeHandles
component={component}
position={position}
breakpoint={breakpoint}
viewportWidth={viewportWidth}
allEffectivePositions={allEffectivePositions}
effectiveGap={effectiveGap}
effectivePadding={effectivePadding}
onResize={onComponentResize}
onResizeEnd={onComponentResizeEnd}
/>
)}
</div>
);
}
// ========================================
// 리사이즈 핸들
// ========================================
interface ResizeHandlesProps {
component: PopComponentDefinitionV5;
position: PopGridPosition;
breakpoint: GridBreakpoint;
viewportWidth: number;
allEffectivePositions: Map<string, PopGridPosition>;
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 (
<>
{/* 오른쪽 핸들 (가로 크기) */}
<div
className="absolute top-0 bottom-0 w-2 cursor-ew-resize bg-primary/20 hover:bg-primary/50 transition-colors"
onMouseDown={handleMouseDown('right')}
style={{ right: '-4px' }}
/>
{/* 아래쪽 핸들 (세로 크기) */}
<div
className="absolute left-0 right-0 h-2 cursor-ns-resize bg-primary/20 hover:bg-primary/50 transition-colors"
onMouseDown={handleMouseDown('bottom')}
style={{ bottom: '-4px' }}
/>
{/* 오른쪽 아래 모서리 (가로+세로) */}
<div
className="absolute h-3 w-3 cursor-nwse-resize bg-primary hover:bg-primary/80 transition-colors rounded-sm"
onMouseDown={handleMouseDown('corner')}
style={{ right: '-6px', bottom: '-6px' }}
/>
</>
);
}
// ========================================
// 컴포넌트 내용 렌더링
// ========================================
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 (
<div className={cn(
"h-full w-full overflow-hidden",
!needsPointerEvents && "pointer-events-none"
)}>
<ActualComp
config={component.config}
label={component.label}
isDesignMode={isDesignMode}
previewPageIndex={previewPageIndex}
componentId={component.id}
screenId={screenId}
currentRowSpan={effectivePosition.rowSpan}
currentColSpan={effectivePosition.colSpan}
onRequestResize={onRequestResize}
/>
</div>
);
}
// 미등록: preview 컴포넌트 또는 기본 플레이스홀더
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden">
{PreviewComponent ? (
<PreviewComponent config={component.config} />
) : (
<span className="text-xs text-gray-400 p-2">
{typeLabel}
</span>
)}
</div>
);
}
// 실제 모드: 컴포넌트 렌더링 (뷰어 모드에서도 리사이즈 지원)
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 (
<div className="h-full w-full overflow-hidden">
<ActualComp
config={component.config}
label={component.label}
componentId={component.id}
screenId={screenId}
currentRowSpan={effectivePosition?.rowSpan}
currentColSpan={effectivePosition?.colSpan}
onRequestResize={onRequestResize}
/>
</div>
);
}
// 미등록 컴포넌트: 플레이스홀더 (fallback)
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-gray-500">{component.label || typeLabel}</span>
</div>
);
}