402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React from "react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import {
|
||
|
|
PopLayoutDataV3,
|
||
|
|
PopLayoutModeKey,
|
||
|
|
PopModeLayoutV3,
|
||
|
|
GridPosition,
|
||
|
|
MODE_RESOLUTIONS,
|
||
|
|
PopComponentDefinition,
|
||
|
|
} from "../types/pop-layout";
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// Props 정의
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
interface PopLayoutRendererProps {
|
||
|
|
/** 레이아웃 데이터 (v3.0) */
|
||
|
|
layout: PopLayoutDataV3;
|
||
|
|
/** 현재 모드 키 (tablet_landscape, tablet_portrait 등) */
|
||
|
|
modeKey: PopLayoutModeKey;
|
||
|
|
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
|
||
|
|
isDesignMode?: boolean;
|
||
|
|
/** 선택된 컴포넌트 ID */
|
||
|
|
selectedComponentId?: string | null;
|
||
|
|
/** 컴포넌트 클릭 시 호출 */
|
||
|
|
onComponentClick?: (componentId: string) => void;
|
||
|
|
/** 배경 클릭 시 호출 (선택 해제용) */
|
||
|
|
onBackgroundClick?: () => void;
|
||
|
|
/** 커스텀 모드 레이아웃 (fallback 등에서 변환된 레이아웃 사용 시) */
|
||
|
|
customModeLayout?: PopModeLayoutV3;
|
||
|
|
/** 추가 className */
|
||
|
|
className?: string;
|
||
|
|
/** 추가 style */
|
||
|
|
style?: React.CSSProperties;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 컴포넌트 타입별 라벨
|
||
|
|
// ========================================
|
||
|
|
const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
||
|
|
"pop-field": "필드",
|
||
|
|
"pop-button": "버튼",
|
||
|
|
"pop-list": "리스트",
|
||
|
|
"pop-indicator": "인디케이터",
|
||
|
|
"pop-scanner": "스캐너",
|
||
|
|
"pop-numpad": "숫자패드",
|
||
|
|
};
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// POP 레이아웃 렌더러 (v3)
|
||
|
|
//
|
||
|
|
// 핵심 역할:
|
||
|
|
// - 디자이너와 뷰어에서 **동일한** 렌더링 결과 보장
|
||
|
|
// - 컴포넌트가 캔버스에 직접 배치 (섹션 없음)
|
||
|
|
// - CSS Grid + 1fr 비율 기반
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
export function PopLayoutRenderer({
|
||
|
|
layout,
|
||
|
|
modeKey,
|
||
|
|
isDesignMode = false,
|
||
|
|
selectedComponentId,
|
||
|
|
onComponentClick,
|
||
|
|
onBackgroundClick,
|
||
|
|
customModeLayout,
|
||
|
|
className,
|
||
|
|
style,
|
||
|
|
}: PopLayoutRendererProps) {
|
||
|
|
const { components, layouts, settings } = layout;
|
||
|
|
const canvasGrid = settings.canvasGrid;
|
||
|
|
|
||
|
|
// 현재 모드의 레이아웃
|
||
|
|
const modeLayout = customModeLayout || layouts[modeKey];
|
||
|
|
|
||
|
|
// 컴포넌트가 없으면 빈 상태 표시
|
||
|
|
if (!modeLayout || Object.keys(modeLayout.componentPositions).length === 0) {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex h-full w-full items-center justify-center bg-gray-50",
|
||
|
|
className
|
||
|
|
)}
|
||
|
|
style={style}
|
||
|
|
onClick={onBackgroundClick}
|
||
|
|
>
|
||
|
|
<div className="text-center text-sm text-gray-400">
|
||
|
|
<p>레이아웃이 설정되지 않았습니다</p>
|
||
|
|
{isDesignMode && <p className="mt-1">컴포넌트를 추가해주세요</p>}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 컴포넌트 ID 목록
|
||
|
|
const componentIds = Object.keys(modeLayout.componentPositions);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn("relative w-full h-full bg-white", className)}
|
||
|
|
style={{
|
||
|
|
// CSS Grid: 디자이너와 동일
|
||
|
|
display: "grid",
|
||
|
|
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
|
||
|
|
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
|
||
|
|
gap: `${canvasGrid.gap}px`,
|
||
|
|
padding: `${canvasGrid.gap}px`,
|
||
|
|
...style,
|
||
|
|
}}
|
||
|
|
onClick={(e) => {
|
||
|
|
if (e.target === e.currentTarget) {
|
||
|
|
onBackgroundClick?.();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* 컴포넌트들 직접 렌더링 */}
|
||
|
|
{componentIds.map((componentId) => {
|
||
|
|
const compDef = components[componentId];
|
||
|
|
const compPos = modeLayout.componentPositions[componentId];
|
||
|
|
|
||
|
|
if (!compDef || !compPos) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ComponentRenderer
|
||
|
|
key={componentId}
|
||
|
|
componentId={componentId}
|
||
|
|
component={compDef}
|
||
|
|
position={compPos}
|
||
|
|
isDesignMode={isDesignMode}
|
||
|
|
isSelected={selectedComponentId === componentId}
|
||
|
|
onComponentClick={() => onComponentClick?.(componentId)}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 컴포넌트 렌더러
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
interface ComponentRendererProps {
|
||
|
|
componentId: string;
|
||
|
|
component: PopComponentDefinition;
|
||
|
|
position: GridPosition;
|
||
|
|
isDesignMode?: boolean;
|
||
|
|
isSelected?: boolean;
|
||
|
|
onComponentClick?: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ComponentRenderer({
|
||
|
|
componentId,
|
||
|
|
component,
|
||
|
|
position,
|
||
|
|
isDesignMode = false,
|
||
|
|
isSelected = false,
|
||
|
|
onComponentClick,
|
||
|
|
}: ComponentRendererProps) {
|
||
|
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
|
||
|
|
isSelected
|
||
|
|
? "border-primary ring-2 ring-primary/30 z-10"
|
||
|
|
: "border-gray-200",
|
||
|
|
isDesignMode && "cursor-pointer hover:border-gray-300"
|
||
|
|
)}
|
||
|
|
style={{
|
||
|
|
gridColumn: `${position.col} / span ${position.colSpan}`,
|
||
|
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||
|
|
}}
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
onComponentClick?.();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
|
||
|
|
{isDesignMode && (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex h-5 shrink-0 items-center border-b px-2",
|
||
|
|
isSelected ? "bg-primary/10" : "bg-gray-50"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<span className="text-[10px] font-medium text-gray-600">
|
||
|
|
{component.label || typeLabel}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 컴포넌트 내용 */}
|
||
|
|
<div className="flex flex-1 items-center justify-center p-2">
|
||
|
|
{renderComponentContent(component, isDesignMode)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 컴포넌트별 렌더링
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
function renderComponentContent(
|
||
|
|
component: PopComponentDefinition,
|
||
|
|
isDesignMode: boolean
|
||
|
|
): React.ReactNode {
|
||
|
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||
|
|
|
||
|
|
// 디자인 모드에서는 플레이스홀더 표시
|
||
|
|
if (isDesignMode) {
|
||
|
|
return (
|
||
|
|
<div className="text-xs text-gray-400 text-center">
|
||
|
|
{typeLabel}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 뷰어 모드: 실제 컴포넌트 렌더링
|
||
|
|
switch (component.type) {
|
||
|
|
case "pop-field":
|
||
|
|
return (
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder={component.label || "입력하세요"}
|
||
|
|
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "pop-button":
|
||
|
|
return (
|
||
|
|
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
|
||
|
|
{component.label || "버튼"}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "pop-list":
|
||
|
|
return (
|
||
|
|
<div className="w-full h-full overflow-auto p-2">
|
||
|
|
<div className="text-xs text-gray-500 text-center">
|
||
|
|
리스트 (데이터 연결 필요)
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "pop-indicator":
|
||
|
|
return (
|
||
|
|
<div className="text-center">
|
||
|
|
<div className="text-2xl font-bold text-primary">0</div>
|
||
|
|
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "pop-scanner":
|
||
|
|
return (
|
||
|
|
<div className="text-center text-gray-500">
|
||
|
|
<div className="text-xs">스캐너</div>
|
||
|
|
<div className="text-[10px]">탭하여 스캔</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "pop-numpad":
|
||
|
|
return (
|
||
|
|
<div className="grid grid-cols-3 gap-1 p-1 w-full">
|
||
|
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
||
|
|
<button
|
||
|
|
key={key}
|
||
|
|
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
|
||
|
|
>
|
||
|
|
{key}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
default:
|
||
|
|
return (
|
||
|
|
<div className="text-xs text-gray-400">
|
||
|
|
{typeLabel}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 헬퍼 함수들 (export)
|
||
|
|
// ========================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 특정 모드에 레이아웃이 설정되어 있는지 확인
|
||
|
|
*/
|
||
|
|
export function hasModeLayout(
|
||
|
|
layout: PopLayoutDataV3,
|
||
|
|
modeKey: PopLayoutModeKey
|
||
|
|
): boolean {
|
||
|
|
const modeLayout = layout.layouts[modeKey];
|
||
|
|
return modeLayout && Object.keys(modeLayout.componentPositions).length > 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 태블릿 가로 모드(기준 모드)가 설정되어 있는지 확인
|
||
|
|
*/
|
||
|
|
export function hasBaseLayout(layout: PopLayoutDataV3): boolean {
|
||
|
|
return hasModeLayout(layout, "tablet_landscape");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 태블릿 가로 모드를 기준으로 다른 모드에 맞게 자동 변환
|
||
|
|
*/
|
||
|
|
export function autoConvertLayout(
|
||
|
|
layout: PopLayoutDataV3,
|
||
|
|
targetModeKey: PopLayoutModeKey
|
||
|
|
): PopModeLayoutV3 {
|
||
|
|
const sourceKey: PopLayoutModeKey = "tablet_landscape";
|
||
|
|
const sourceLayout = layout.layouts[sourceKey];
|
||
|
|
const sourceRes = MODE_RESOLUTIONS[sourceKey];
|
||
|
|
const targetRes = MODE_RESOLUTIONS[targetModeKey];
|
||
|
|
|
||
|
|
// 비율 계산
|
||
|
|
const widthRatio = targetRes.width / sourceRes.width;
|
||
|
|
const heightRatio = targetRes.height / sourceRes.height;
|
||
|
|
|
||
|
|
// 가로 → 세로 변환인지 확인
|
||
|
|
const isOrientationChange =
|
||
|
|
sourceRes.width > sourceRes.height !== targetRes.width > targetRes.height;
|
||
|
|
|
||
|
|
// 컴포넌트 위치 변환
|
||
|
|
const convertedPositions: Record<string, GridPosition> = {};
|
||
|
|
let currentRow = 1;
|
||
|
|
|
||
|
|
// 컴포넌트를 row, col 순으로 정렬
|
||
|
|
const sortedComponentIds = Object.keys(sourceLayout.componentPositions).sort(
|
||
|
|
(a, b) => {
|
||
|
|
const posA = sourceLayout.componentPositions[a];
|
||
|
|
const posB = sourceLayout.componentPositions[b];
|
||
|
|
if (posA.row !== posB.row) return posA.row - posB.row;
|
||
|
|
return posA.col - posB.col;
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
for (const componentId of sortedComponentIds) {
|
||
|
|
const sourcePos = sourceLayout.componentPositions[componentId];
|
||
|
|
|
||
|
|
if (isOrientationChange) {
|
||
|
|
// 가로 → 세로: 세로 스택 방식
|
||
|
|
const canvasColumns = layout.settings.canvasGrid.columns;
|
||
|
|
convertedPositions[componentId] = {
|
||
|
|
col: 1,
|
||
|
|
row: currentRow,
|
||
|
|
colSpan: canvasColumns,
|
||
|
|
rowSpan: Math.max(3, Math.round(sourcePos.rowSpan * 1.5)),
|
||
|
|
};
|
||
|
|
currentRow += convertedPositions[componentId].rowSpan + 1;
|
||
|
|
} else {
|
||
|
|
// 같은 방향: 비율 변환
|
||
|
|
convertedPositions[componentId] = {
|
||
|
|
col: Math.max(1, Math.round(sourcePos.col * widthRatio)),
|
||
|
|
row: Math.max(1, Math.round(sourcePos.row * heightRatio)),
|
||
|
|
colSpan: Math.max(1, Math.round(sourcePos.colSpan * widthRatio)),
|
||
|
|
rowSpan: Math.max(1, Math.round(sourcePos.rowSpan * heightRatio)),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
componentPositions: convertedPositions,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 현재 모드에 맞는 레이아웃 반환 (없으면 자동 변환)
|
||
|
|
*/
|
||
|
|
export function getEffectiveModeLayout(
|
||
|
|
layout: PopLayoutDataV3,
|
||
|
|
targetModeKey: PopLayoutModeKey
|
||
|
|
): {
|
||
|
|
modeLayout: PopModeLayoutV3;
|
||
|
|
isConverted: boolean;
|
||
|
|
sourceModeKey: PopLayoutModeKey;
|
||
|
|
} {
|
||
|
|
// 해당 모드에 레이아웃이 있으면 그대로 사용
|
||
|
|
if (hasModeLayout(layout, targetModeKey)) {
|
||
|
|
return {
|
||
|
|
modeLayout: layout.layouts[targetModeKey],
|
||
|
|
isConverted: false,
|
||
|
|
sourceModeKey: targetModeKey,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 없으면 태블릿 가로 모드를 기준으로 자동 변환
|
||
|
|
return {
|
||
|
|
modeLayout: autoConvertLayout(layout, targetModeKey),
|
||
|
|
isConverted: true,
|
||
|
|
sourceModeKey: "tablet_landscape",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export default PopLayoutRenderer;
|