"use client"; import React, { useMemo, useState, useCallback, useRef } from "react"; import { useDrag, useDrop } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopLayoutDataV4, PopContainerV4, PopComponentDefinitionV4, PopResponsiveRuleV4, PopSizeConstraintV4, PopComponentType, } from "../types/pop-layout"; // 드래그 아이템 타입 const DND_COMPONENT_REORDER = "POP_COMPONENT_REORDER"; interface DragItem { type: string; componentId: string; containerId: string; index: number; } // ======================================== // Props 정의 // ======================================== interface PopFlexRendererProps { /** v4 레이아웃 데이터 */ layout: PopLayoutDataV4; /** 현재 뷰포트 너비 (반응형 규칙 적용용) */ viewportWidth: number; /** 현재 뷰포트 모드 (오버라이드 병합용) */ currentMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; /** 임시 레이아웃 (고정 전 미리보기) */ tempLayout?: PopContainerV4 | null; /** 디자인 모드 여부 */ isDesignMode?: boolean; /** 선택된 컴포넌트 ID */ selectedComponentId?: string | null; /** 컴포넌트 클릭 */ onComponentClick?: (componentId: string) => void; /** 컨테이너 클릭 */ onContainerClick?: (containerId: string) => void; /** 배경 클릭 */ onBackgroundClick?: () => void; /** 컴포넌트 크기 변경 */ onComponentResize?: (componentId: string, size: Partial) => void; /** 컴포넌트 순서 변경 */ onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void; /** 추가 className */ className?: string; } // ======================================== // 컴포넌트 타입별 라벨 // ======================================== const COMPONENT_TYPE_LABELS: Record = { "pop-field": "필드", "pop-button": "버튼", "pop-list": "리스트", "pop-indicator": "인디케이터", "pop-scanner": "스캐너", "pop-numpad": "숫자패드", "pop-spacer": "스페이서", "pop-break": "줄바꿈", }; // ======================================== // v4 Flexbox 렌더러 // // 핵심 역할: // - v4 레이아웃을 Flexbox CSS로 렌더링 // - 제약조건(fill/fixed/hug) 기반 크기 계산 // - 반응형 규칙(breakpoint) 자동 적용 // ======================================== export function PopFlexRenderer({ layout, viewportWidth, currentMode = "tablet_landscape", tempLayout, isDesignMode = false, selectedComponentId, onComponentClick, onContainerClick, onBackgroundClick, onComponentResize, onReorderComponent, className, }: PopFlexRendererProps) { const { root, components, settings, overrides } = layout; // 오버라이드 병합 로직 (컨테이너) 🔥 const getMergedRoot = (): PopContainerV4 => { // 1. 임시 레이아웃이 있으면 최우선 (고정 전 미리보기) if (tempLayout) { return tempLayout; } // 2. 기본 모드면 root 그대로 반환 if (currentMode === "tablet_landscape") { return root; } // 3. 다른 모드면 오버라이드 병합 const override = overrides?.[currentMode]?.containers?.root; if (override) { return { ...root, ...override, // 오버라이드 속성으로 덮어쓰기 }; } // 4. 오버라이드 없으면 기본값 return root; }; // visibility 체크 함수 🆕 const isComponentVisible = (component: PopComponentDefinitionV4): boolean => { if (!component.visibility) return true; // 기본값: 표시 const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility]; return modeVisibility !== false; // undefined도 true로 취급 }; // 컴포넌트 오버라이드 병합 🆕 const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => { if (currentMode === "tablet_landscape") return baseComponent; const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id]; if (!componentOverride) return baseComponent; // 깊은 병합 (config, size) return { ...baseComponent, ...componentOverride, size: { ...baseComponent.size, ...componentOverride.size }, config: { ...baseComponent.config, ...componentOverride.config }, }; }; const effectiveRoot = getMergedRoot(); // 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링 if (effectiveRoot.children.length === 0) { return (
); } return (
{ if (e.target === e.currentTarget) { onBackgroundClick?.(); } }} > {/* 루트 컨테이너 렌더링 (병합된 레이아웃 사용) */}
); } // ======================================== // 컨테이너 렌더러 (재귀) // ======================================== interface ContainerRendererProps { container: PopContainerV4; components: Record; viewportWidth: number; settings: PopLayoutDataV4["settings"]; currentMode: string; overrides: PopLayoutDataV4["overrides"]; isDesignMode?: boolean; selectedComponentId?: string | null; onComponentClick?: (componentId: string) => void; onContainerClick?: (containerId: string) => void; onComponentResize?: (componentId: string, size: Partial) => void; onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void; depth?: number; } function ContainerRenderer({ container, components, viewportWidth, settings, currentMode, overrides, isDesignMode = false, selectedComponentId, onComponentClick, onContainerClick, onComponentResize, onReorderComponent, depth = 0, }: ContainerRendererProps) { // visibility 체크 함수 const isComponentVisible = (component: PopComponentDefinitionV4): boolean => { if (!component.visibility) return true; // 기본값: 표시 const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility]; return modeVisibility !== false; // undefined도 true로 취급 }; // 컴포넌트 오버라이드 병합 const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => { if (currentMode === "tablet_landscape") return baseComponent; const componentOverride = overrides?.[currentMode as keyof typeof overrides]?.components?.[baseComponent.id]; if (!componentOverride) return baseComponent; // 깊은 병합 (config, size) return { ...baseComponent, ...componentOverride, size: { ...baseComponent.size, ...componentOverride.size }, config: { ...baseComponent.config, ...componentOverride.config }, }; }; // 반응형 규칙 적용 const effectiveContainer = useMemo(() => { return applyResponsiveRules(container, viewportWidth); }, [container, viewportWidth]); // 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용) const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH; // Flexbox 스타일 계산 (useMemo는 조건문 전에 호출해야 함) const containerStyle = useMemo((): React.CSSProperties => { const { direction, wrap, gap, alignItems, justifyContent, padding } = effectiveContainer; // gap과 padding도 스케일 적용 const scaledGap = gap * scale; const scaledPadding = padding ? padding * scale : undefined; return { display: "flex", flexDirection: direction === "horizontal" ? "row" : "column", flexWrap: wrap ? "wrap" : "nowrap", gap: `${scaledGap}px`, alignItems: mapAlignment(alignItems), justifyContent: mapJustify(justifyContent), padding: scaledPadding ? `${scaledPadding}px` : undefined, width: "100%", minHeight: depth === 0 ? "100%" : undefined, }; }, [effectiveContainer, depth, scale]); // 숨김 처리 if (effectiveContainer.hidden) { return null; } return (
0 && "border border-dashed border-gray-300 rounded" )} style={containerStyle} onClick={(e) => { if (e.target === e.currentTarget) { onContainerClick?.(container.id); } }} > {effectiveContainer.children.map((child, index) => { // 중첩 컨테이너인 경우 if (typeof child === "object") { return ( ); } // 컴포넌트 ID인 경우 const componentId = child; const baseComponent = components[componentId]; if (!baseComponent) return null; // visibility 체크 (모드별 숨김) if (!isComponentVisible(baseComponent)) { return null; } // 반응형 숨김 처리 (픽셀 기반) if (baseComponent.hideBelow && viewportWidth < baseComponent.hideBelow) { return null; } // 오버라이드 병합 const mergedComponent = getMergedComponent(baseComponent); // pop-break 특수 처리 if (mergedComponent.type === "pop-break") { return (
onComponentClick?.(componentId)} > {isDesignMode && ( 줄바꿈 )}
); } return ( onComponentClick?.(componentId)} onResize={onComponentResize} /> ); })}
); } // ======================================== // 드래그 가능한 컴포넌트 래퍼 // ======================================== interface DraggableComponentWrapperProps { componentId: string; containerId: string; index: number; isDesignMode: boolean; onReorder?: (containerId: string, fromIndex: number, toIndex: number) => void; children: React.ReactNode; } function DraggableComponentWrapper({ componentId, containerId, index, isDesignMode, onReorder, children, }: DraggableComponentWrapperProps) { // 디자인 모드가 아니면 그냥 children 반환 (훅 호출 전에 체크) // DndProvider가 없는 환경에서 useDrag/useDrop 훅 호출 방지 if (!isDesignMode) { return <>{children}; } // 디자인 모드일 때만 드래그 기능 활성화 return ( {children} ); } // 디자인 모드 전용 내부 컴포넌트 (DndProvider 필요) function DraggableComponentWrapperInner({ componentId, containerId, index, onReorder, children, }: Omit) { const ref = useRef(null); const [{ isDragging }, drag] = useDrag({ type: DND_COMPONENT_REORDER, item: (): DragItem => ({ type: DND_COMPONENT_REORDER, componentId, containerId, index, }), collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }); const [{ isOver, canDrop }, drop] = useDrop({ accept: DND_COMPONENT_REORDER, canDrop: (item: DragItem) => { // 같은 컨테이너 내에서만 이동 가능 (일단은) return item.containerId === containerId && item.index !== index; }, drop: (item: DragItem) => { if (item.index !== index && onReorder) { onReorder(containerId, item.index, index); } }, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }); // drag와 drop 합치기 drag(drop(ref)); return (
{children} {/* 드롭 인디케이터 */} {isOver && canDrop && (
)}
); } // ======================================== // v4 컴포넌트 렌더러 (리사이즈 핸들 포함) // ======================================== interface ComponentRendererV4Props { componentId: string; component: PopComponentDefinitionV4; settings: PopLayoutDataV4["settings"]; viewportWidth: number; isDesignMode?: boolean; isSelected?: boolean; onClick?: () => void; onResize?: (componentId: string, size: Partial) => void; } function ComponentRendererV4({ componentId, component, settings, viewportWidth, isDesignMode = false, isSelected = false, onClick, onResize, }: ComponentRendererV4Props) { const { size, alignSelf, type, label } = component; const containerRef = useRef(null); // 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용) const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH; // 리사이즈 상태 const [isResizing, setIsResizing] = useState(false); const [resizeDirection, setResizeDirection] = useState<"width" | "height" | "both" | null>(null); const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null); // 크기 스타일 계산 (스케일 적용) const sizeStyle = useMemo((): React.CSSProperties => { return calculateSizeStyle(size, settings, scale); }, [size, settings, scale]); // alignSelf 스타일 const alignStyle: React.CSSProperties = alignSelf ? { alignSelf: mapAlignment(alignSelf) } : {}; const typeLabel = COMPONENT_TYPE_LABELS[type] || type; // 리사이즈 시작 const handleResizeStart = useCallback(( e: React.MouseEvent, direction: "width" | "height" | "both" ) => { e.stopPropagation(); e.preventDefault(); if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); resizeStartRef.current = { x: e.clientX, y: e.clientY, width: rect.width, height: rect.height, }; setIsResizing(true); setResizeDirection(direction); }, []); // 리사이즈 중 useCallback((e: MouseEvent) => { if (!isResizing || !resizeStartRef.current || !onResize) return; const deltaX = e.clientX - resizeStartRef.current.x; const deltaY = e.clientY - resizeStartRef.current.y; const updates: Partial = {}; if (resizeDirection === "width" || resizeDirection === "both") { const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX)); updates.width = "fixed"; updates.fixedWidth = newWidth; } if (resizeDirection === "height" || resizeDirection === "both") { const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY)); updates.height = "fixed"; updates.fixedHeight = newHeight; } onResize(componentId, updates); }, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]); // 리사이즈 종료 및 이벤트 등록 React.useEffect(() => { if (!isResizing) return; const handleMouseMove = (e: MouseEvent) => { if (!resizeStartRef.current || !onResize) return; const deltaX = e.clientX - resizeStartRef.current.x; const deltaY = e.clientY - resizeStartRef.current.y; const updates: Partial = {}; if (resizeDirection === "width" || resizeDirection === "both") { const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX)); updates.width = "fixed"; updates.fixedWidth = newWidth; } if (resizeDirection === "height" || resizeDirection === "both") { const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY)); updates.height = "fixed"; updates.fixedHeight = newHeight; } onResize(componentId, updates); }; const handleMouseUp = () => { setIsResizing(false); setResizeDirection(null); resizeStartRef.current = null; }; window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]); return (
{ e.stopPropagation(); if (!isResizing) { onClick?.(); } }} > {/* 컴포넌트 라벨 (디자인 모드에서만) */} {isDesignMode && (
{label || typeLabel}
)} {/* 컴포넌트 내용 */}
{renderComponentContent(component, isDesignMode, settings)}
{/* 리사이즈 핸들 (디자인 모드 + 선택 시에만) */} {isDesignMode && isSelected && onResize && ( <> {/* 오른쪽 핸들 (너비 조정) */}
handleResizeStart(e, "width")} title="너비 조정" >
{/* 아래쪽 핸들 (높이 조정) */}
handleResizeStart(e, "height")} title="높이 조정" >
{/* 오른쪽 아래 핸들 (너비 + 높이 동시 조정) */}
handleResizeStart(e, "both")} title="크기 조정" >
)}
); } // ======================================== // 헬퍼 함수들 // ======================================== /** * 반응형 규칙 적용 */ function applyResponsiveRules( container: PopContainerV4, viewportWidth: number ): PopContainerV4 & { hidden?: boolean } { if (!container.responsive || container.responsive.length === 0) { return container; } // 현재 뷰포트에 적용되는 규칙 찾기 (가장 큰 breakpoint부터) const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint); const applicableRule = sortedRules.find((rule) => viewportWidth <= rule.breakpoint); if (!applicableRule) { return container; } return { ...container, direction: applicableRule.direction ?? container.direction, gap: applicableRule.gap ?? container.gap, hidden: applicableRule.hidden ?? false, }; } /** * 기준 뷰포트 너비 (10인치 태블릿 가로) * 이 크기를 기준으로 컴포넌트 크기가 비율 조정됨 */ const BASE_VIEWPORT_WIDTH = 1024; /** * 크기 제약 → CSS 스타일 변환 * @param scale - 뷰포트 비율 (viewportWidth / BASE_VIEWPORT_WIDTH) */ function calculateSizeStyle( size: PopSizeConstraintV4, settings: PopLayoutDataV4["settings"], scale: number = 1 ): React.CSSProperties { const style: React.CSSProperties = {}; // 스케일된 터치 최소 크기 const scaledTouchMin = settings.touchTargetMin * scale; // 너비 switch (size.width) { case "fixed": // fixed 크기도 비율에 맞게 스케일 style.width = size.fixedWidth ? `${size.fixedWidth * scale}px` : "auto"; style.flexShrink = 0; break; case "fill": style.flex = 1; style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0; style.maxWidth = size.maxWidth ? `${size.maxWidth * scale}px` : undefined; break; case "hug": style.width = "auto"; style.flexShrink = 0; break; } // 높이 switch (size.height) { case "fixed": const scaledFixedHeight = (size.fixedHeight || settings.touchTargetMin) * scale; const minHeight = Math.max(scaledFixedHeight, scaledTouchMin); style.height = `${minHeight}px`; break; case "fill": style.flexGrow = 1; style.minHeight = size.minHeight ? `${Math.max(size.minHeight * scale, scaledTouchMin)}px` : `${scaledTouchMin}px`; break; case "hug": style.height = "auto"; style.minHeight = `${scaledTouchMin}px`; break; } return style; } /** * alignItems 값 변환 */ function mapAlignment(value: string): React.CSSProperties["alignItems"] { switch (value) { case "start": return "flex-start"; case "end": return "flex-end"; case "center": return "center"; case "stretch": return "stretch"; default: return "stretch"; } } /** * justifyContent 값 변환 */ function mapJustify(value: string): React.CSSProperties["justifyContent"] { switch (value) { case "start": return "flex-start"; case "end": return "flex-end"; case "center": return "center"; case "space-between": return "space-between"; default: return "flex-start"; } } /** * 컴포넌트별 내용 렌더링 */ function renderComponentContent( component: PopComponentDefinitionV4, isDesignMode: boolean, settings: PopLayoutDataV4["settings"] ): React.ReactNode { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // 디자인 모드에서는 플레이스홀더 표시 if (isDesignMode) { // Spacer는 디자인 모드에서 점선 배경으로 표시 if (component.type === "pop-spacer") { return (
빈 공간
); } return (
{typeLabel}
); } // 뷰어 모드: 실제 컴포넌트 렌더링 switch (component.type) { case "pop-field": return ( ); case "pop-button": return ( ); case "pop-list": return (
리스트 (데이터 연결 필요)
); case "pop-indicator": return (
0
{component.label || "지표"}
); case "pop-scanner": return (
스캐너
탭하여 스캔
); case "pop-numpad": return (
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => ( ))}
); case "pop-spacer": // 실제 모드에서 Spacer는 완전히 투명 (공간만 차지) return null; default: return (
{typeLabel}
); } } export default PopFlexRenderer;