2026-02-04 14:14:48 +09:00
|
|
|
"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;
|
2026-02-04 18:23:59 +09:00
|
|
|
/** 현재 뷰포트 모드 (오버라이드 병합용) */
|
|
|
|
|
currentMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
|
|
|
|
/** 임시 레이아웃 (고정 전 미리보기) */
|
|
|
|
|
tempLayout?: PopContainerV4 | null;
|
2026-02-04 14:14:48 +09:00
|
|
|
/** 디자인 모드 여부 */
|
|
|
|
|
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": "스페이서",
|
2026-02-04 18:23:59 +09:00
|
|
|
"pop-break": "줄바꿈",
|
2026-02-04 14:14:48 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// v4 Flexbox 렌더러
|
|
|
|
|
//
|
|
|
|
|
// 핵심 역할:
|
|
|
|
|
// - v4 레이아웃을 Flexbox CSS로 렌더링
|
|
|
|
|
// - 제약조건(fill/fixed/hug) 기반 크기 계산
|
|
|
|
|
// - 반응형 규칙(breakpoint) 자동 적용
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
export function PopFlexRenderer({
|
|
|
|
|
layout,
|
|
|
|
|
viewportWidth,
|
2026-02-04 18:23:59 +09:00
|
|
|
currentMode = "tablet_landscape",
|
|
|
|
|
tempLayout,
|
2026-02-04 14:14:48 +09:00
|
|
|
isDesignMode = false,
|
|
|
|
|
selectedComponentId,
|
|
|
|
|
onComponentClick,
|
|
|
|
|
onContainerClick,
|
|
|
|
|
onBackgroundClick,
|
|
|
|
|
onComponentResize,
|
|
|
|
|
onReorderComponent,
|
|
|
|
|
className,
|
|
|
|
|
}: PopFlexRendererProps) {
|
2026-02-04 18:23:59 +09:00
|
|
|
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();
|
2026-02-04 14:14:48 +09:00
|
|
|
|
|
|
|
|
// 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링
|
2026-02-04 18:23:59 +09:00
|
|
|
if (effectiveRoot.children.length === 0) {
|
2026-02-04 14:14:48 +09:00
|
|
|
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?.();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-02-04 18:23:59 +09:00
|
|
|
{/* 루트 컨테이너 렌더링 (병합된 레이아웃 사용) */}
|
2026-02-04 14:14:48 +09:00
|
|
|
<ContainerRenderer
|
2026-02-04 18:23:59 +09:00
|
|
|
container={effectiveRoot}
|
2026-02-04 14:14:48 +09:00
|
|
|
components={components}
|
|
|
|
|
viewportWidth={viewportWidth}
|
|
|
|
|
settings={settings}
|
2026-02-04 18:23:59 +09:00
|
|
|
currentMode={currentMode}
|
|
|
|
|
overrides={overrides}
|
2026-02-04 14:14:48 +09:00
|
|
|
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"];
|
2026-02-04 18:23:59 +09:00
|
|
|
currentMode: string;
|
|
|
|
|
overrides: PopLayoutDataV4["overrides"];
|
2026-02-04 14:14:48 +09:00
|
|
|
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,
|
2026-02-04 18:23:59 +09:00
|
|
|
currentMode,
|
|
|
|
|
overrides,
|
2026-02-04 14:14:48 +09:00
|
|
|
isDesignMode = false,
|
|
|
|
|
selectedComponentId,
|
|
|
|
|
onComponentClick,
|
|
|
|
|
onContainerClick,
|
|
|
|
|
onComponentResize,
|
|
|
|
|
onReorderComponent,
|
|
|
|
|
depth = 0,
|
|
|
|
|
}: ContainerRendererProps) {
|
2026-02-04 18:23:59 +09:00
|
|
|
// 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 },
|
|
|
|
|
};
|
|
|
|
|
};
|
2026-02-04 14:14:48 +09:00
|
|
|
// 반응형 규칙 적용
|
|
|
|
|
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}
|
2026-02-04 18:23:59 +09:00
|
|
|
currentMode={currentMode}
|
|
|
|
|
overrides={overrides}
|
2026-02-04 14:14:48 +09:00
|
|
|
isDesignMode={isDesignMode}
|
|
|
|
|
selectedComponentId={selectedComponentId}
|
|
|
|
|
onComponentClick={onComponentClick}
|
|
|
|
|
onContainerClick={onContainerClick}
|
|
|
|
|
onComponentResize={onComponentResize}
|
|
|
|
|
onReorderComponent={onReorderComponent}
|
|
|
|
|
depth={depth + 1}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 ID인 경우
|
|
|
|
|
const componentId = child;
|
2026-02-04 18:23:59 +09:00
|
|
|
const baseComponent = components[componentId];
|
|
|
|
|
if (!baseComponent) return null;
|
2026-02-04 14:14:48 +09:00
|
|
|
|
2026-02-04 18:23:59 +09:00
|
|
|
// visibility 체크 (모드별 숨김)
|
|
|
|
|
if (!isComponentVisible(baseComponent)) {
|
2026-02-04 14:14:48 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 18:23:59 +09:00
|
|
|
// 반응형 숨김 처리 (픽셀 기반)
|
|
|
|
|
if (baseComponent.hideBelow && viewportWidth < baseComponent.hideBelow) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 오버라이드 병합
|
|
|
|
|
const mergedComponent = getMergedComponent(baseComponent);
|
|
|
|
|
|
|
|
|
|
// pop-break 특수 처리
|
|
|
|
|
if (mergedComponent.type === "pop-break") {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={componentId}
|
|
|
|
|
className={cn(
|
|
|
|
|
"w-full",
|
|
|
|
|
isDesignMode
|
|
|
|
|
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
|
|
|
|
|
: "h-0"
|
|
|
|
|
)}
|
|
|
|
|
style={{ flexBasis: "100%" }}
|
|
|
|
|
onClick={() => onComponentClick?.(componentId)}
|
|
|
|
|
>
|
|
|
|
|
{isDesignMode && (
|
|
|
|
|
<span className="text-xs text-gray-400">줄바꿈</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 14:14:48 +09:00
|
|
|
return (
|
|
|
|
|
<DraggableComponentWrapper
|
|
|
|
|
key={componentId}
|
|
|
|
|
componentId={componentId}
|
|
|
|
|
containerId={container.id}
|
|
|
|
|
index={index}
|
|
|
|
|
isDesignMode={isDesignMode}
|
|
|
|
|
onReorder={onReorderComponent}
|
|
|
|
|
>
|
|
|
|
|
<ComponentRendererV4
|
|
|
|
|
componentId={componentId}
|
2026-02-04 18:23:59 +09:00
|
|
|
component={mergedComponent}
|
2026-02-04 14:14:48 +09:00
|
|
|
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;
|