2026-02-05 14:24:14 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useMemo } from "react";
|
2026-02-05 19:16:23 +09:00
|
|
|
|
import { useDrag } from "react-dnd";
|
2026-02-05 14:24:14 +09:00
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-02-05 19:16:23 +09:00
|
|
|
|
import { DND_ITEM_TYPES } from "../constants";
|
2026-02-05 14:24:14 +09:00
|
|
|
|
import {
|
|
|
|
|
|
PopLayoutDataV5,
|
|
|
|
|
|
PopComponentDefinitionV5,
|
|
|
|
|
|
PopGridPosition,
|
|
|
|
|
|
GridMode,
|
|
|
|
|
|
GRID_BREAKPOINTS,
|
2026-02-05 19:16:23 +09:00
|
|
|
|
GridBreakpoint,
|
2026-02-05 14:24:14 +09:00
|
|
|
|
detectGridMode,
|
|
|
|
|
|
PopComponentType,
|
|
|
|
|
|
} from "../types/pop-layout";
|
2026-02-05 19:16:23 +09:00
|
|
|
|
import {
|
|
|
|
|
|
convertAndResolvePositions,
|
|
|
|
|
|
isOutOfBounds,
|
|
|
|
|
|
isOverlapping,
|
|
|
|
|
|
getAllEffectivePositions,
|
|
|
|
|
|
} from "../utils/gridUtils";
|
2026-02-05 14:24:14 +09:00
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// Props
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface PopRendererProps {
|
|
|
|
|
|
/** v5 레이아웃 데이터 */
|
|
|
|
|
|
layout: PopLayoutDataV5;
|
|
|
|
|
|
/** 현재 뷰포트 너비 */
|
|
|
|
|
|
viewportWidth: number;
|
|
|
|
|
|
/** 현재 모드 (자동 감지 또는 수동 지정) */
|
|
|
|
|
|
currentMode?: GridMode;
|
|
|
|
|
|
/** 디자인 모드 여부 */
|
|
|
|
|
|
isDesignMode?: boolean;
|
|
|
|
|
|
/** 그리드 가이드 표시 여부 */
|
|
|
|
|
|
showGridGuide?: boolean;
|
|
|
|
|
|
/** 선택된 컴포넌트 ID */
|
|
|
|
|
|
selectedComponentId?: string | null;
|
|
|
|
|
|
/** 컴포넌트 클릭 */
|
|
|
|
|
|
onComponentClick?: (componentId: string) => void;
|
|
|
|
|
|
/** 배경 클릭 */
|
|
|
|
|
|
onBackgroundClick?: () => void;
|
2026-02-05 19:16:23 +09:00
|
|
|
|
/** 컴포넌트 이동 */
|
|
|
|
|
|
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
|
|
|
|
|
/** 컴포넌트 크기 조정 */
|
|
|
|
|
|
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
|
|
|
|
|
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
|
|
|
|
|
|
onComponentResizeEnd?: (componentId: string) => void;
|
2026-02-05 14:24:14 +09:00
|
|
|
|
/** 추가 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,
|
2026-02-05 19:16:23 +09:00
|
|
|
|
onComponentMove,
|
|
|
|
|
|
onComponentResize,
|
|
|
|
|
|
onComponentResizeEnd,
|
2026-02-05 14:24:14 +09:00
|
|
|
|
className,
|
|
|
|
|
|
}: PopRendererProps) {
|
|
|
|
|
|
const { gridConfig, components, overrides } = layout;
|
|
|
|
|
|
|
|
|
|
|
|
// 현재 모드 (자동 감지 또는 지정)
|
|
|
|
|
|
const mode = currentMode || detectGridMode(viewportWidth);
|
|
|
|
|
|
const breakpoint = GRID_BREAKPOINTS[mode];
|
|
|
|
|
|
|
|
|
|
|
|
// CSS Grid 스타일
|
|
|
|
|
|
const gridStyle = useMemo((): React.CSSProperties => ({
|
|
|
|
|
|
display: "grid",
|
|
|
|
|
|
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
|
|
|
|
|
gridAutoRows: `${breakpoint.rowHeight}px`,
|
|
|
|
|
|
gap: `${breakpoint.gap}px`,
|
|
|
|
|
|
padding: `${breakpoint.padding}px`,
|
|
|
|
|
|
minHeight: "100%",
|
|
|
|
|
|
backgroundColor: "#ffffff",
|
|
|
|
|
|
position: "relative",
|
|
|
|
|
|
}), [breakpoint]);
|
|
|
|
|
|
|
|
|
|
|
|
// 그리드 가이드 셀 생성
|
|
|
|
|
|
const gridCells = useMemo(() => {
|
|
|
|
|
|
if (!isDesignMode || !showGridGuide) return [];
|
|
|
|
|
|
|
|
|
|
|
|
const cells = [];
|
|
|
|
|
|
const rowCount = 20; // 충분한 행 수
|
|
|
|
|
|
|
|
|
|
|
|
for (let row = 1; row <= rowCount; row++) {
|
|
|
|
|
|
for (let col = 1; col <= breakpoint.columns; col++) {
|
|
|
|
|
|
cells.push({
|
|
|
|
|
|
id: `cell-${col}-${row}`,
|
|
|
|
|
|
col,
|
|
|
|
|
|
row
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return cells;
|
|
|
|
|
|
}, [isDesignMode, showGridGuide, breakpoint.columns]);
|
|
|
|
|
|
|
|
|
|
|
|
// visibility 체크
|
|
|
|
|
|
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
|
|
|
|
|
|
if (!comp.visibility) return true;
|
|
|
|
|
|
const modeVisibility = comp.visibility[mode];
|
|
|
|
|
|
return modeVisibility !== false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-05 19:16:23 +09:00
|
|
|
|
// 자동 재배치된 위치 계산 (오버라이드 없을 때)
|
|
|
|
|
|
const autoResolvedPositions = useMemo(() => {
|
|
|
|
|
|
const componentsArray = Object.entries(components).map(([id, comp]) => ({
|
|
|
|
|
|
id,
|
|
|
|
|
|
position: comp.position,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
return convertAndResolvePositions(componentsArray, mode);
|
|
|
|
|
|
}, [components, mode]);
|
|
|
|
|
|
|
2026-02-05 14:24:14 +09:00
|
|
|
|
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
|
|
|
|
|
|
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
|
|
|
|
|
|
return {
|
2026-02-05 19:16:23 +09:00
|
|
|
|
gridColumn: `${position.col} / span ${position.colSpan}`,
|
2026-02-05 14:24:14 +09:00
|
|
|
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-05 19:16:23 +09:00
|
|
|
|
// 오버라이드 적용 또는 자동 재배치
|
2026-02-05 14:24:14 +09:00
|
|
|
|
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
|
2026-02-05 19:16:23 +09:00
|
|
|
|
// 1순위: 오버라이드가 있으면 사용
|
2026-02-05 14:24:14 +09:00
|
|
|
|
const override = overrides?.[mode]?.positions?.[comp.id];
|
|
|
|
|
|
if (override) {
|
|
|
|
|
|
return { ...comp.position, ...override };
|
|
|
|
|
|
}
|
2026-02-05 19:16:23 +09:00
|
|
|
|
|
|
|
|
|
|
// 2순위: 자동 재배치된 위치 사용
|
|
|
|
|
|
const autoResolved = autoResolvedPositions.find(p => p.id === comp.id);
|
|
|
|
|
|
if (autoResolved) {
|
|
|
|
|
|
return autoResolved.position;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3순위: 원본 위치 (12칸 모드)
|
2026-02-05 14:24:14 +09:00
|
|
|
|
return comp.position;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 오버라이드 숨김 체크
|
|
|
|
|
|
const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => {
|
|
|
|
|
|
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-05 19:16:23 +09:00
|
|
|
|
// 모든 컴포넌트의 유효 위치 계산 (리사이즈 겹침 검사용)
|
|
|
|
|
|
const effectivePositionsMap = useMemo(() =>
|
|
|
|
|
|
getAllEffectivePositions(layout, mode),
|
|
|
|
|
|
[layout, mode]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-05 14:24:14 +09:00
|
|
|
|
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로 위에 표시) */}
|
2026-02-05 19:16:23 +09:00
|
|
|
|
{/* 디자인 모드에서는 초과 컴포넌트를 그리드에서 제외 (오른쪽 별도 영역에 표시) */}
|
2026-02-05 14:24:14 +09:00
|
|
|
|
{Object.values(components).map((comp) => {
|
|
|
|
|
|
// visibility 체크
|
|
|
|
|
|
if (!isVisible(comp)) return null;
|
|
|
|
|
|
|
|
|
|
|
|
// 오버라이드 숨김 체크
|
|
|
|
|
|
if (isHiddenByOverride(comp)) return null;
|
|
|
|
|
|
|
2026-02-05 19:16:23 +09:00
|
|
|
|
// 오버라이드 위치 가져오기 (있으면)
|
|
|
|
|
|
const overridePos = overrides?.[mode]?.positions?.[comp.id];
|
|
|
|
|
|
const overridePosition = overridePos
|
|
|
|
|
|
? { ...comp.position, ...overridePos }
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
// 초과 컴포넌트 체크 (오버라이드 고려)
|
|
|
|
|
|
const outOfBounds = isOutOfBounds(comp.position, mode, overridePosition);
|
|
|
|
|
|
|
|
|
|
|
|
// 디자인 모드에서 초과 컴포넌트는 그리드에 렌더링하지 않음
|
|
|
|
|
|
// (PopCanvas의 OutOfBoundsPanel에서 별도로 렌더링)
|
|
|
|
|
|
if (isDesignMode && outOfBounds) return null;
|
|
|
|
|
|
|
2026-02-05 14:24:14 +09:00
|
|
|
|
const position = getEffectivePosition(comp);
|
|
|
|
|
|
const positionStyle = convertPosition(position);
|
|
|
|
|
|
const isSelected = selectedComponentId === comp.id;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-05 19:16:23 +09:00
|
|
|
|
<DraggableComponent
|
2026-02-05 14:24:14 +09:00
|
|
|
|
key={comp.id}
|
2026-02-05 19:16:23 +09:00
|
|
|
|
component={comp}
|
|
|
|
|
|
position={position}
|
|
|
|
|
|
positionStyle={positionStyle}
|
|
|
|
|
|
isSelected={isSelected}
|
|
|
|
|
|
isDesignMode={isDesignMode}
|
|
|
|
|
|
isOutOfBounds={false}
|
|
|
|
|
|
breakpoint={breakpoint}
|
|
|
|
|
|
viewportWidth={viewportWidth}
|
|
|
|
|
|
allEffectivePositions={effectivePositionsMap}
|
|
|
|
|
|
onComponentClick={onComponentClick}
|
|
|
|
|
|
onComponentMove={onComponentMove}
|
|
|
|
|
|
onComponentResize={onComponentResize}
|
|
|
|
|
|
onComponentResizeEnd={onComponentResizeEnd}
|
|
|
|
|
|
/>
|
2026-02-05 14:24:14 +09:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 19:16:23 +09:00
|
|
|
|
// ========================================
|
|
|
|
|
|
// 드래그 가능한 컴포넌트 래퍼
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface DraggableComponentProps {
|
|
|
|
|
|
component: PopComponentDefinitionV5;
|
|
|
|
|
|
position: PopGridPosition;
|
|
|
|
|
|
positionStyle: React.CSSProperties;
|
|
|
|
|
|
isSelected: boolean;
|
|
|
|
|
|
isDesignMode: boolean;
|
|
|
|
|
|
isOutOfBounds: boolean;
|
|
|
|
|
|
breakpoint: GridBreakpoint;
|
|
|
|
|
|
viewportWidth: number;
|
|
|
|
|
|
allEffectivePositions: Map<string, PopGridPosition>;
|
|
|
|
|
|
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,
|
|
|
|
|
|
isOutOfBounds,
|
|
|
|
|
|
breakpoint,
|
|
|
|
|
|
viewportWidth,
|
|
|
|
|
|
allEffectivePositions,
|
|
|
|
|
|
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-visible z-10",
|
|
|
|
|
|
// 초과 컴포넌트 스타일 (디자인 모드에서만)
|
|
|
|
|
|
isDesignMode && isOutOfBounds && "opacity-40 bg-gray-400/30",
|
|
|
|
|
|
!isOutOfBounds && "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);
|
|
|
|
|
|
}}
|
|
|
|
|
|
title={
|
|
|
|
|
|
isDesignMode && isOutOfBounds
|
|
|
|
|
|
? `이 컴포넌트는 ${breakpoint.label}에서 표시되지 않습니다`
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ComponentContent
|
|
|
|
|
|
component={component}
|
|
|
|
|
|
effectivePosition={position}
|
|
|
|
|
|
isDesignMode={isDesignMode}
|
|
|
|
|
|
isSelected={isSelected}
|
|
|
|
|
|
isOutOfBounds={isOutOfBounds}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 리사이즈 핸들 (선택된 컴포넌트만, 초과 아닐 때만) */}
|
|
|
|
|
|
{isDesignMode && isSelected && !isOutOfBounds && onComponentResize && (
|
|
|
|
|
|
<ResizeHandles
|
|
|
|
|
|
component={component}
|
|
|
|
|
|
position={position}
|
|
|
|
|
|
breakpoint={breakpoint}
|
|
|
|
|
|
viewportWidth={viewportWidth}
|
|
|
|
|
|
allEffectivePositions={allEffectivePositions}
|
|
|
|
|
|
onResize={onComponentResize}
|
|
|
|
|
|
onResizeEnd={onComponentResizeEnd}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// 리사이즈 핸들
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface ResizeHandlesProps {
|
|
|
|
|
|
component: PopComponentDefinitionV5;
|
|
|
|
|
|
position: PopGridPosition;
|
|
|
|
|
|
breakpoint: GridBreakpoint;
|
|
|
|
|
|
viewportWidth: number;
|
|
|
|
|
|
allEffectivePositions: Map<string, PopGridPosition>;
|
|
|
|
|
|
onResize: (componentId: string, newPosition: PopGridPosition) => void;
|
|
|
|
|
|
onResizeEnd?: (componentId: string) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ResizeHandles({
|
|
|
|
|
|
component,
|
|
|
|
|
|
position,
|
|
|
|
|
|
breakpoint,
|
|
|
|
|
|
viewportWidth,
|
|
|
|
|
|
allEffectivePositions,
|
|
|
|
|
|
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*(칸수-1)
|
|
|
|
|
|
const availableWidth = viewportWidth - breakpoint.padding * 2 - breakpoint.gap * (breakpoint.columns - 1);
|
|
|
|
|
|
const cellWidth = availableWidth / breakpoint.columns + breakpoint.gap; // 셀 너비 + gap 단위
|
|
|
|
|
|
const cellHeight = breakpoint.rowHeight + breakpoint.gap;
|
|
|
|
|
|
|
|
|
|
|
|
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' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:24:14 +09:00
|
|
|
|
// ========================================
|
|
|
|
|
|
// 컴포넌트 내용 렌더링
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface ComponentContentProps {
|
|
|
|
|
|
component: PopComponentDefinitionV5;
|
2026-02-05 19:16:23 +09:00
|
|
|
|
effectivePosition: PopGridPosition;
|
2026-02-05 14:24:14 +09:00
|
|
|
|
isDesignMode: boolean;
|
|
|
|
|
|
isSelected: boolean;
|
2026-02-05 19:16:23 +09:00
|
|
|
|
isOutOfBounds: boolean;
|
2026-02-05 14:24:14 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 19:16:23 +09:00
|
|
|
|
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, isOutOfBounds }: ComponentContentProps) {
|
2026-02-05 14:24:14 +09:00
|
|
|
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
|
|
|
|
|
|
|
|
|
|
|
// 디자인 모드: 플레이스홀더 표시
|
|
|
|
|
|
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>
|
2026-02-05 19:16:23 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 초과 표시 */}
|
|
|
|
|
|
{isOutOfBounds && (
|
|
|
|
|
|
<span className="ml-1 text-[9px] text-orange-600 font-semibold">
|
|
|
|
|
|
밖
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-02-05 14:24:14 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 내용 */}
|
|
|
|
|
|
<div className="flex flex-1 items-center justify-center p-2">
|
2026-02-05 19:16:23 +09:00
|
|
|
|
<span className={cn(
|
|
|
|
|
|
"text-xs",
|
|
|
|
|
|
isOutOfBounds ? "text-gray-500" : "text-gray-400"
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{typeLabel}
|
|
|
|
|
|
</span>
|
2026-02-05 14:24:14 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-05 19:16:23 +09:00
|
|
|
|
{/* 위치 정보 표시 (유효 위치 사용) */}
|
2026-02-05 14:24:14 +09:00
|
|
|
|
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
|
2026-02-05 19:16:23 +09:00
|
|
|
|
{effectivePosition.col},{effectivePosition.row}
|
|
|
|
|
|
({effectivePosition.colSpan}×{effectivePosition.rowSpan})
|
2026-02-05 14:24:14 +09:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|