feat(pop): v4 레이아웃 비율 스케일링 시스템 구현
- PopFlexRenderer에 BASE_VIEWPORT_WIDTH(1024px) 기준 스케일 계산 추가 - 컴포넌트 크기(fixedWidth/Height), gap, padding에 scale 적용 - 뷰어에서 viewportWidth 동적 감지 및 최대 1366px 제한 - 디자인 모드에서는 scale=1 유지, 뷰어에서만 비율 적용 - DndProvider 없는 환경에서 useDrag/useDrop 에러 방지 - v4 레이아웃 뷰어 렌더링 지원 (isPopLayoutV4 체크)
This commit is contained in:
796
frontend/components/pop/designer/renderers/PopFlexRenderer.tsx
Normal file
796
frontend/components/pop/designer/renderers/PopFlexRenderer.tsx
Normal file
@@ -0,0 +1,796 @@
|
||||
"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;
|
||||
/** 디자인 모드 여부 */
|
||||
isDesignMode?: boolean;
|
||||
/** 선택된 컴포넌트 ID */
|
||||
selectedComponentId?: string | null;
|
||||
/** 컴포넌트 클릭 */
|
||||
onComponentClick?: (componentId: string) => void;
|
||||
/** 컨테이너 클릭 */
|
||||
onContainerClick?: (containerId: string) => void;
|
||||
/** 배경 클릭 */
|
||||
onBackgroundClick?: () => void;
|
||||
/** 컴포넌트 크기 변경 */
|
||||
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
||||
/** 컴포넌트 순서 변경 */
|
||||
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 타입별 라벨
|
||||
// ========================================
|
||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||
"pop-field": "필드",
|
||||
"pop-button": "버튼",
|
||||
"pop-list": "리스트",
|
||||
"pop-indicator": "인디케이터",
|
||||
"pop-scanner": "스캐너",
|
||||
"pop-numpad": "숫자패드",
|
||||
"pop-spacer": "스페이서",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// v4 Flexbox 렌더러
|
||||
//
|
||||
// 핵심 역할:
|
||||
// - v4 레이아웃을 Flexbox CSS로 렌더링
|
||||
// - 제약조건(fill/fixed/hug) 기반 크기 계산
|
||||
// - 반응형 규칙(breakpoint) 자동 적용
|
||||
// ========================================
|
||||
|
||||
export function PopFlexRenderer({
|
||||
layout,
|
||||
viewportWidth,
|
||||
isDesignMode = false,
|
||||
selectedComponentId,
|
||||
onComponentClick,
|
||||
onContainerClick,
|
||||
onBackgroundClick,
|
||||
onComponentResize,
|
||||
onReorderComponent,
|
||||
className,
|
||||
}: PopFlexRendererProps) {
|
||||
const { root, components, settings } = layout;
|
||||
|
||||
// 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링
|
||||
if (root.children.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn("h-full w-full", className)}
|
||||
onClick={onBackgroundClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative min-h-full w-full bg-white", className)}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onBackgroundClick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 루트 컨테이너 렌더링 */}
|
||||
<ContainerRenderer
|
||||
container={root}
|
||||
components={components}
|
||||
viewportWidth={viewportWidth}
|
||||
settings={settings}
|
||||
isDesignMode={isDesignMode}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onComponentClick={onComponentClick}
|
||||
onContainerClick={onContainerClick}
|
||||
onComponentResize={onComponentResize}
|
||||
onReorderComponent={onReorderComponent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 컨테이너 렌더러 (재귀)
|
||||
// ========================================
|
||||
|
||||
interface ContainerRendererProps {
|
||||
container: PopContainerV4;
|
||||
components: Record<string, PopComponentDefinitionV4>;
|
||||
viewportWidth: number;
|
||||
settings: PopLayoutDataV4["settings"];
|
||||
isDesignMode?: boolean;
|
||||
selectedComponentId?: string | null;
|
||||
onComponentClick?: (componentId: string) => void;
|
||||
onContainerClick?: (containerId: string) => void;
|
||||
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
||||
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
function ContainerRenderer({
|
||||
container,
|
||||
components,
|
||||
viewportWidth,
|
||||
settings,
|
||||
isDesignMode = false,
|
||||
selectedComponentId,
|
||||
onComponentClick,
|
||||
onContainerClick,
|
||||
onComponentResize,
|
||||
onReorderComponent,
|
||||
depth = 0,
|
||||
}: ContainerRendererProps) {
|
||||
// 반응형 규칙 적용
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
isDesignMode && depth > 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 (
|
||||
<ContainerRenderer
|
||||
key={child.id}
|
||||
container={child}
|
||||
components={components}
|
||||
viewportWidth={viewportWidth}
|
||||
settings={settings}
|
||||
isDesignMode={isDesignMode}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onComponentClick={onComponentClick}
|
||||
onContainerClick={onContainerClick}
|
||||
onComponentResize={onComponentResize}
|
||||
onReorderComponent={onReorderComponent}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 컴포넌트 ID인 경우
|
||||
const componentId = child;
|
||||
const compDef = components[componentId];
|
||||
if (!compDef) return null;
|
||||
|
||||
// 반응형 숨김 처리
|
||||
if (compDef.hideBelow && viewportWidth < compDef.hideBelow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DraggableComponentWrapper
|
||||
key={componentId}
|
||||
componentId={componentId}
|
||||
containerId={container.id}
|
||||
index={index}
|
||||
isDesignMode={isDesignMode}
|
||||
onReorder={onReorderComponent}
|
||||
>
|
||||
<ComponentRendererV4
|
||||
componentId={componentId}
|
||||
component={compDef}
|
||||
settings={settings}
|
||||
viewportWidth={viewportWidth}
|
||||
isDesignMode={isDesignMode}
|
||||
isSelected={selectedComponentId === componentId}
|
||||
onClick={() => onComponentClick?.(componentId)}
|
||||
onResize={onComponentResize}
|
||||
/>
|
||||
</DraggableComponentWrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 드래그 가능한 컴포넌트 래퍼
|
||||
// ========================================
|
||||
|
||||
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 (
|
||||
<DraggableComponentWrapperInner
|
||||
componentId={componentId}
|
||||
containerId={containerId}
|
||||
index={index}
|
||||
onReorder={onReorder}
|
||||
>
|
||||
{children}
|
||||
</DraggableComponentWrapperInner>
|
||||
);
|
||||
}
|
||||
|
||||
// 디자인 모드 전용 내부 컴포넌트 (DndProvider 필요)
|
||||
function DraggableComponentWrapperInner({
|
||||
componentId,
|
||||
containerId,
|
||||
index,
|
||||
onReorder,
|
||||
children,
|
||||
}: Omit<DraggableComponentWrapperProps, "isDesignMode">) {
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative",
|
||||
isDragging && "opacity-50",
|
||||
isOver && canDrop && "ring-2 ring-blue-500 ring-offset-2"
|
||||
)}
|
||||
style={{ cursor: isDragging ? "grabbing" : "grab" }}
|
||||
>
|
||||
{children}
|
||||
{/* 드롭 인디케이터 */}
|
||||
{isOver && canDrop && (
|
||||
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none rounded" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// v4 컴포넌트 렌더러 (리사이즈 핸들 포함)
|
||||
// ========================================
|
||||
|
||||
interface ComponentRendererV4Props {
|
||||
componentId: string;
|
||||
component: PopComponentDefinitionV4;
|
||||
settings: PopLayoutDataV4["settings"];
|
||||
viewportWidth: number;
|
||||
isDesignMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
onResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
||||
}
|
||||
|
||||
function ComponentRendererV4({
|
||||
componentId,
|
||||
component,
|
||||
settings,
|
||||
viewportWidth,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onResize,
|
||||
}: ComponentRendererV4Props) {
|
||||
const { size, alignSelf, type, label } = component;
|
||||
const containerRef = useRef<HTMLDivElement>(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<PopSizeConstraintV4> = {};
|
||||
|
||||
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<PopSizeConstraintV4> = {};
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex flex-col overflow-visible rounded-lg border-2 bg-white transition-all",
|
||||
isSelected
|
||||
? "border-primary ring-2 ring-primary/30 z-10"
|
||||
: "border-gray-200",
|
||||
isDesignMode && !isResizing && "cursor-pointer hover:border-gray-300",
|
||||
isResizing && "select-none"
|
||||
)}
|
||||
style={{
|
||||
...sizeStyle,
|
||||
...alignStyle,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isResizing) {
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
|
||||
{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">
|
||||
{label || typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 내용 */}
|
||||
<div className="flex flex-1 items-center justify-center p-2 overflow-hidden">
|
||||
{renderComponentContent(component, isDesignMode, settings)}
|
||||
</div>
|
||||
|
||||
{/* 리사이즈 핸들 (디자인 모드 + 선택 시에만) */}
|
||||
{isDesignMode && isSelected && onResize && (
|
||||
<>
|
||||
{/* 오른쪽 핸들 (너비 조정) */}
|
||||
<div
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize hover:bg-primary/80 flex items-center justify-center z-20"
|
||||
onMouseDown={(e) => handleResizeStart(e, "width")}
|
||||
title="너비 조정"
|
||||
>
|
||||
<div className="w-0.5 h-4 bg-white rounded" />
|
||||
</div>
|
||||
|
||||
{/* 아래쪽 핸들 (높이 조정) */}
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-3 w-8 bg-primary rounded cursor-ns-resize hover:bg-primary/80 flex items-center justify-center z-20"
|
||||
onMouseDown={(e) => handleResizeStart(e, "height")}
|
||||
title="높이 조정"
|
||||
>
|
||||
<div className="h-0.5 w-4 bg-white rounded" />
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 아래 핸들 (너비 + 높이 동시 조정) */}
|
||||
<div
|
||||
className="absolute right-0 bottom-0 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-primary rounded cursor-nwse-resize hover:bg-primary/80 flex items-center justify-center z-20"
|
||||
onMouseDown={(e) => handleResizeStart(e, "both")}
|
||||
title="크기 조정"
|
||||
>
|
||||
<div className="w-2 h-2 border-r-2 border-b-2 border-white" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 헬퍼 함수들
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 반응형 규칙 적용
|
||||
*/
|
||||
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 (
|
||||
<div className="h-full w-full flex items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50/50 rounded">
|
||||
<span className="text-xs text-gray-400">빈 공간</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
||||
case "pop-spacer":
|
||||
// 실제 모드에서 Spacer는 완전히 투명 (공간만 차지)
|
||||
return null;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-xs text-gray-400">
|
||||
{typeLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PopFlexRenderer;
|
||||
Reference in New Issue
Block a user