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

565 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<PopComponentType, string> = {
"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 (
<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}
/>
);
}
// 뷰어 모드: 드래그 없는 일반 렌더링
return (
<div
key={comp.id}
className="relative rounded-lg border-2 border-gray-200 bg-white transition-all overflow-hidden z-10"
style={positionStyle}
>
<ComponentContent
component={comp}
effectivePosition={position}
isDesignMode={false}
isSelected={false}
/>
</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;
}
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 (
<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}
/>
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
{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;
}
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 (
<div className="flex h-full w-full flex-col">
{/* 헤더 */}
<div
className={cn(
"flex h-5 shrink-0 items-center border-b px-2",
isSelected ? "bg-primary/10 border-primary" : "bg-gray-50 border-gray-200"
)}
>
<span className={cn(
"text-[10px] font-medium truncate",
isSelected ? "text-primary" : "text-gray-600"
)}>
{component.label || typeLabel}
</span>
</div>
{/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */}
<div className="flex flex-1 items-center justify-center overflow-hidden">
{PreviewComponent ? (
<PreviewComponent config={component.config} />
) : (
<span className="text-xs text-gray-400 p-2">
{typeLabel}
</span>
)}
</div>
{/* 위치 정보 표시 (유효 위치 사용) */}
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
{effectivePosition.col},{effectivePosition.row}
({effectivePosition.colSpan}×{effectivePosition.rowSpan})
</div>
</div>
);
}
// 실제 모드: 컴포넌트 렌더링
return renderActualComponent(component);
}
// ========================================
// 실제 컴포넌트 렌더링 (뷰어 모드)
// ========================================
function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[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>
);
}