933 lines
31 KiB
TypeScript
933 lines
31 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useRef, useState, useEffect, useMemo } from "react";
|
||
import { useDrop } from "react-dnd";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
PopLayoutDataV5,
|
||
PopComponentDefinitionV5,
|
||
PopComponentType,
|
||
PopGridPosition,
|
||
GridMode,
|
||
GRID_BREAKPOINTS,
|
||
DEFAULT_COMPONENT_GRID_SIZE,
|
||
} from "./types/pop-layout";
|
||
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react";
|
||
import { useDrag } from "react-dnd";
|
||
import { Button } from "@/components/ui/button";
|
||
import { toast } from "sonner";
|
||
import PopRenderer from "./renderers/PopRenderer";
|
||
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, isOutOfBounds } from "./utils/gridUtils";
|
||
import { DND_ITEM_TYPES } from "./constants";
|
||
|
||
/**
|
||
* 캔버스 내 상대 좌표 → 그리드 좌표 변환
|
||
* @param relX 캔버스 내 X 좌표 (패딩 포함)
|
||
* @param relY 캔버스 내 Y 좌표 (패딩 포함)
|
||
*/
|
||
function calcGridPosition(
|
||
relX: number,
|
||
relY: number,
|
||
canvasWidth: number,
|
||
columns: number,
|
||
rowHeight: number,
|
||
gap: number,
|
||
padding: number
|
||
): { col: number; row: number } {
|
||
// 패딩 제외한 좌표
|
||
const x = relX - padding;
|
||
const y = relY - padding;
|
||
|
||
// 사용 가능한 너비 (패딩과 gap 제외)
|
||
const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1);
|
||
const colWidth = availableWidth / columns;
|
||
|
||
// 셀+gap 단위로 계산
|
||
const cellStride = colWidth + gap;
|
||
const rowStride = rowHeight + gap;
|
||
|
||
// 그리드 좌표 (1부터 시작)
|
||
const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1));
|
||
const row = Math.max(1, Math.floor(y / rowStride) + 1);
|
||
|
||
return { col, row };
|
||
}
|
||
|
||
// 드래그 아이템 타입 정의
|
||
interface DragItemComponent {
|
||
type: typeof DND_ITEM_TYPES.COMPONENT;
|
||
componentType: PopComponentType;
|
||
}
|
||
|
||
interface DragItemMoveComponent {
|
||
componentId: string;
|
||
originalPosition: PopGridPosition;
|
||
}
|
||
|
||
// ========================================
|
||
// 프리셋 해상도 (4개 모드)
|
||
// ========================================
|
||
const VIEWPORT_PRESETS = [
|
||
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, height: 667, icon: Smartphone },
|
||
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 667, height: 375, icon: Smartphone },
|
||
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 768, height: 1024, icon: Tablet },
|
||
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, height: 768, icon: Tablet },
|
||
] as const;
|
||
|
||
type ViewportPreset = GridMode;
|
||
|
||
// 기본 프리셋 (태블릿 가로)
|
||
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
|
||
|
||
// ========================================
|
||
// Props
|
||
// ========================================
|
||
interface PopCanvasProps {
|
||
layout: PopLayoutDataV5;
|
||
selectedComponentId: string | null;
|
||
currentMode: GridMode;
|
||
onModeChange: (mode: GridMode) => void;
|
||
onSelectComponent: (id: string | null) => void;
|
||
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
|
||
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
|
||
onDeleteComponent: (componentId: string) => void;
|
||
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||
onResizeEnd?: (componentId: string) => void;
|
||
onHideComponent?: (componentId: string) => void;
|
||
onUnhideComponent?: (componentId: string) => void;
|
||
onLockLayout?: () => void;
|
||
onResetOverride?: (mode: GridMode) => void;
|
||
}
|
||
|
||
// ========================================
|
||
// PopCanvas: 그리드 캔버스
|
||
// ========================================
|
||
|
||
export default function PopCanvas({
|
||
layout,
|
||
selectedComponentId,
|
||
currentMode,
|
||
onModeChange,
|
||
onSelectComponent,
|
||
onDropComponent,
|
||
onUpdateComponent,
|
||
onDeleteComponent,
|
||
onMoveComponent,
|
||
onResizeComponent,
|
||
onResizeEnd,
|
||
onHideComponent,
|
||
onUnhideComponent,
|
||
onLockLayout,
|
||
onResetOverride,
|
||
}: PopCanvasProps) {
|
||
// 줌 상태
|
||
const [canvasScale, setCanvasScale] = useState(0.8);
|
||
|
||
// 커스텀 뷰포트 크기
|
||
const [customWidth, setCustomWidth] = useState(1024);
|
||
const [customHeight, setCustomHeight] = useState(768);
|
||
|
||
// 그리드 가이드 표시 여부
|
||
const [showGridGuide, setShowGridGuide] = useState(true);
|
||
|
||
// 패닝 상태
|
||
const [isPanning, setIsPanning] = useState(false);
|
||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const canvasRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 현재 뷰포트 해상도
|
||
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
|
||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||
|
||
// 그리드 라벨 계산
|
||
const gridLabels = useMemo(() => {
|
||
const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1);
|
||
const rowLabels = Array.from({ length: 20 }, (_, i) => i + 1);
|
||
return { columnLabels, rowLabels };
|
||
}, [breakpoint.columns]);
|
||
|
||
// 줌 컨트롤
|
||
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
||
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
||
const handleZoomFit = () => setCanvasScale(1.0);
|
||
|
||
// 모드 변경
|
||
const handleViewportChange = (mode: GridMode) => {
|
||
onModeChange(mode);
|
||
const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!;
|
||
setCustomWidth(presetData.width);
|
||
setCustomHeight(presetData.height);
|
||
};
|
||
|
||
// 패닝
|
||
const handlePanStart = (e: React.MouseEvent) => {
|
||
const isMiddleButton = e.button === 1;
|
||
if (isMiddleButton || isSpacePressed) {
|
||
setIsPanning(true);
|
||
setPanStart({ x: e.clientX, y: e.clientY });
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
const handlePanMove = (e: React.MouseEvent) => {
|
||
if (!isPanning || !containerRef.current) return;
|
||
const deltaX = e.clientX - panStart.x;
|
||
const deltaY = e.clientY - panStart.y;
|
||
containerRef.current.scrollLeft -= deltaX;
|
||
containerRef.current.scrollTop -= deltaY;
|
||
setPanStart({ x: e.clientX, y: e.clientY });
|
||
};
|
||
|
||
const handlePanEnd = () => setIsPanning(false);
|
||
|
||
// Ctrl + 휠로 줌 조정
|
||
const handleWheel = (e: React.WheelEvent) => {
|
||
if (e.ctrlKey || e.metaKey) {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
|
||
}
|
||
};
|
||
|
||
// Space 키 감지
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true);
|
||
};
|
||
const handleKeyUp = (e: KeyboardEvent) => {
|
||
if (e.code === "Space") setIsSpacePressed(false);
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
window.addEventListener("keyup", handleKeyUp);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKeyDown);
|
||
window.removeEventListener("keyup", handleKeyUp);
|
||
};
|
||
}, [isSpacePressed]);
|
||
|
||
// 통합 드롭 핸들러 (팔레트에서 추가 + 컴포넌트 이동)
|
||
const [{ isOver, canDrop }, drop] = useDrop(
|
||
() => ({
|
||
accept: [DND_ITEM_TYPES.COMPONENT, DND_ITEM_TYPES.MOVE_COMPONENT],
|
||
drop: (item: DragItemComponent | DragItemMoveComponent, monitor) => {
|
||
if (!canvasRef.current) return;
|
||
|
||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||
const itemType = monitor.getItemType();
|
||
|
||
// 팔레트에서 새 컴포넌트 추가 - 마우스 위치 기준
|
||
if (itemType === DND_ITEM_TYPES.COMPONENT) {
|
||
const offset = monitor.getClientOffset();
|
||
if (!offset) return;
|
||
|
||
// 캔버스 내 상대 좌표 (스케일 보정)
|
||
// canvasRect는 scale 적용된 크기이므로, 상대 좌표를 scale로 나눠야 실제 좌표
|
||
const relX = (offset.x - canvasRect.left) / canvasScale;
|
||
const relY = (offset.y - canvasRect.top) / canvasScale;
|
||
|
||
// 그리드 좌표 계산
|
||
const gridPos = calcGridPosition(
|
||
relX,
|
||
relY,
|
||
customWidth,
|
||
breakpoint.columns,
|
||
breakpoint.rowHeight,
|
||
breakpoint.gap,
|
||
breakpoint.padding
|
||
);
|
||
|
||
const dragItem = item as DragItemComponent;
|
||
const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[dragItem.componentType];
|
||
|
||
const candidatePosition: PopGridPosition = {
|
||
col: gridPos.col,
|
||
row: gridPos.row,
|
||
colSpan: defaultSize.colSpan,
|
||
rowSpan: defaultSize.rowSpan,
|
||
};
|
||
|
||
// 현재 모드에서의 유효 위치들로 중첩 검사
|
||
const effectivePositions = getAllEffectivePositions(layout, currentMode);
|
||
const existingPositions = Array.from(effectivePositions.values());
|
||
|
||
const hasOverlap = existingPositions.some(pos =>
|
||
isOverlapping(candidatePosition, pos)
|
||
);
|
||
|
||
let finalPosition: PopGridPosition;
|
||
|
||
if (hasOverlap) {
|
||
finalPosition = findNextEmptyPosition(
|
||
existingPositions,
|
||
defaultSize.colSpan,
|
||
defaultSize.rowSpan,
|
||
breakpoint.columns
|
||
);
|
||
toast.info("겹치는 위치입니다. 빈 위치로 자동 배치됩니다.");
|
||
} else {
|
||
finalPosition = candidatePosition;
|
||
}
|
||
|
||
onDropComponent(dragItem.componentType, finalPosition);
|
||
}
|
||
|
||
// 기존 컴포넌트 이동 - 마우스 위치 기준
|
||
if (itemType === DND_ITEM_TYPES.MOVE_COMPONENT) {
|
||
const offset = monitor.getClientOffset();
|
||
if (!offset) return;
|
||
|
||
// 캔버스 내 상대 좌표 (스케일 보정)
|
||
const relX = (offset.x - canvasRect.left) / canvasScale;
|
||
const relY = (offset.y - canvasRect.top) / canvasScale;
|
||
|
||
const gridPos = calcGridPosition(
|
||
relX,
|
||
relY,
|
||
customWidth,
|
||
breakpoint.columns,
|
||
breakpoint.rowHeight,
|
||
breakpoint.gap,
|
||
breakpoint.padding
|
||
);
|
||
|
||
const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean };
|
||
|
||
// 현재 모드에서의 유효 위치들 가져오기
|
||
const effectivePositions = getAllEffectivePositions(layout, currentMode);
|
||
|
||
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
|
||
// 초과 컴포넌트(OutOfBoundsPanel에서 드래그)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
||
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
|
||
const componentData = layout.components[dragItem.componentId];
|
||
|
||
if (!currentEffectivePos && !componentData) return;
|
||
|
||
const sourcePosition = currentEffectivePos || componentData.position;
|
||
|
||
// colSpan이 현재 모드의 columns를 초과하면 제한
|
||
const adjustedColSpan = Math.min(sourcePosition.colSpan, breakpoint.columns);
|
||
|
||
// 드롭 위치 + 크기가 범위를 초과하면 드롭 위치를 자동 조정
|
||
let adjustedCol = gridPos.col;
|
||
if (adjustedCol + adjustedColSpan - 1 > breakpoint.columns) {
|
||
adjustedCol = Math.max(1, breakpoint.columns - adjustedColSpan + 1);
|
||
}
|
||
|
||
const newPosition: PopGridPosition = {
|
||
col: adjustedCol,
|
||
row: gridPos.row,
|
||
colSpan: adjustedColSpan,
|
||
rowSpan: sourcePosition.rowSpan,
|
||
};
|
||
|
||
// 자기 자신 제외한 다른 컴포넌트들의 유효 위치와 겹침 체크
|
||
const hasOverlap = Array.from(effectivePositions.entries()).some(([id, pos]) => {
|
||
if (id === dragItem.componentId) return false; // 자기 자신 제외
|
||
return isOverlapping(newPosition, pos);
|
||
});
|
||
|
||
if (hasOverlap) {
|
||
toast.error("이 위치로 이동할 수 없습니다 (다른 컴포넌트와 겹침)");
|
||
return;
|
||
}
|
||
|
||
// 이동 처리 (숨김 컴포넌트의 경우 handleMoveComponent에서 숨김 해제도 함께 처리됨)
|
||
onMoveComponent?.(dragItem.componentId, newPosition);
|
||
|
||
// 숨김 패널에서 드래그한 경우 안내 메시지
|
||
if (dragItem.fromHidden) {
|
||
toast.info("컴포넌트가 다시 표시됩니다");
|
||
}
|
||
}
|
||
},
|
||
collect: (monitor) => ({
|
||
isOver: monitor.isOver(),
|
||
canDrop: monitor.canDrop(),
|
||
}),
|
||
}),
|
||
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, customHeight]
|
||
);
|
||
|
||
drop(canvasRef);
|
||
|
||
// 빈 상태 체크
|
||
const isEmpty = Object.keys(layout.components).length === 0;
|
||
|
||
// 숨김 처리된 컴포넌트 목록
|
||
const hiddenComponentIds = useMemo(() => {
|
||
return layout.overrides?.[currentMode]?.hidden || [];
|
||
}, [layout.overrides, currentMode]);
|
||
|
||
// 숨김 처리된 컴포넌트 객체 목록
|
||
const hiddenComponents = useMemo(() => {
|
||
return hiddenComponentIds
|
||
.map(id => layout.components[id])
|
||
.filter(Boolean);
|
||
}, [hiddenComponentIds, layout.components]);
|
||
|
||
// 초과 컴포넌트 목록 (오른쪽 영역에 표시)
|
||
// 오버라이드가 있는 컴포넌트는 오버라이드 위치로 판단
|
||
// 숨김 처리된 컴포넌트는 제외
|
||
const outOfBoundsComponents = useMemo(() => {
|
||
return Object.values(layout.components).filter(comp => {
|
||
// 숨김 처리된 컴포넌트는 초과 목록에서 제외
|
||
if (hiddenComponentIds.includes(comp.id)) return false;
|
||
|
||
// 오버라이드 위치 확인
|
||
const overridePos = layout.overrides?.[currentMode]?.positions?.[comp.id];
|
||
const overridePosition = overridePos
|
||
? { ...comp.position, ...overridePos }
|
||
: null;
|
||
|
||
return isOutOfBounds(comp.position, currentMode, overridePosition);
|
||
});
|
||
}, [layout.components, layout.overrides, currentMode, hiddenComponentIds]);
|
||
|
||
// 12칸 모드가 아닐 때만 패널 표시
|
||
// 초과 컴포넌트: 있을 때만 표시
|
||
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
|
||
const showOutOfBoundsPanel = currentMode !== "tablet_landscape" && outOfBoundsComponents.length > 0;
|
||
const hasGridComponents = Object.keys(layout.components).length > 0;
|
||
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
|
||
const showRightPanel = showOutOfBoundsPanel || showHiddenPanel;
|
||
|
||
return (
|
||
<div className="flex h-full flex-col bg-gray-50">
|
||
{/* 상단 컨트롤 */}
|
||
<div className="flex items-center gap-2 border-b bg-white px-4 py-2">
|
||
{/* 모드 프리셋 버튼 */}
|
||
<div className="flex gap-1">
|
||
{VIEWPORT_PRESETS.map((preset) => {
|
||
const Icon = preset.icon;
|
||
const isActive = currentMode === preset.id;
|
||
const isDefault = preset.id === DEFAULT_PRESET;
|
||
|
||
return (
|
||
<Button
|
||
key={preset.id}
|
||
variant={isActive ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => handleViewportChange(preset.id as GridMode)}
|
||
className={cn(
|
||
"h-8 gap-1 text-xs",
|
||
isActive && "shadow-sm"
|
||
)}
|
||
>
|
||
<Icon className="h-3 w-3" />
|
||
{preset.shortLabel}
|
||
{isDefault && " (기본)"}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="h-4 w-px bg-gray-300" />
|
||
|
||
{/* 고정/되돌리기 버튼 (기본 모드 아닐 때만 표시) */}
|
||
{currentMode !== DEFAULT_PRESET && (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onLockLayout}
|
||
className="h-8 gap-1 text-xs"
|
||
>
|
||
<Lock className="h-3 w-3" />
|
||
고정
|
||
</Button>
|
||
|
||
{layout.overrides?.[currentMode] && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => onResetOverride?.(currentMode)}
|
||
className="h-8 gap-1 text-xs"
|
||
>
|
||
<RotateCcw className="h-3 w-3" />
|
||
자동으로 되돌리기
|
||
</Button>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<div className="h-4 w-px bg-gray-300" />
|
||
|
||
{/* 해상도 표시 */}
|
||
<div className="text-xs text-muted-foreground">
|
||
{customWidth} × {customHeight}
|
||
</div>
|
||
|
||
<div className="flex-1" />
|
||
|
||
{/* 줌 컨트롤 */}
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-xs text-muted-foreground">
|
||
{Math.round(canvasScale * 100)}%
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={handleZoomOut}
|
||
disabled={canvasScale <= 0.3}
|
||
className="h-7 w-7"
|
||
>
|
||
<ZoomOut className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={handleZoomFit}
|
||
className="h-7 w-7"
|
||
>
|
||
<Maximize2 className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={handleZoomIn}
|
||
disabled={canvasScale >= 1.5}
|
||
className="h-7 w-7"
|
||
>
|
||
<ZoomIn className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="h-4 w-px bg-gray-300" />
|
||
|
||
{/* 그리드 가이드 토글 */}
|
||
<Button
|
||
variant={showGridGuide ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => setShowGridGuide(!showGridGuide)}
|
||
className="h-8 text-xs"
|
||
>
|
||
그리드 {showGridGuide ? "ON" : "OFF"}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 캔버스 영역 */}
|
||
<div
|
||
ref={containerRef}
|
||
className={cn(
|
||
"canvas-scroll-area relative flex-1 overflow-auto bg-gray-100",
|
||
isSpacePressed && "cursor-grab",
|
||
isPanning && "cursor-grabbing"
|
||
)}
|
||
onMouseDown={handlePanStart}
|
||
onMouseMove={handlePanMove}
|
||
onMouseUp={handlePanEnd}
|
||
onMouseLeave={handlePanEnd}
|
||
onWheel={handleWheel}
|
||
>
|
||
<div
|
||
className="relative mx-auto my-8 origin-top overflow-visible flex gap-4"
|
||
style={{
|
||
width: showRightPanel
|
||
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
|
||
: `${customWidth + 32}px`,
|
||
minHeight: `${customHeight + 32}px`,
|
||
transform: `scale(${canvasScale})`,
|
||
}}
|
||
>
|
||
{/* 그리드 + 라벨 영역 */}
|
||
<div className="relative">
|
||
{/* 그리드 라벨 영역 */}
|
||
{showGridGuide && (
|
||
<>
|
||
{/* 열 라벨 (상단) */}
|
||
<div
|
||
className="flex absolute top-0 left-8"
|
||
style={{
|
||
gap: `${breakpoint.gap}px`,
|
||
paddingLeft: `${breakpoint.padding}px`,
|
||
}}
|
||
>
|
||
{gridLabels.columnLabels.map((num) => (
|
||
<div
|
||
key={`col-${num}`}
|
||
className="flex items-center justify-center text-xs font-semibold text-blue-500"
|
||
style={{
|
||
width: `calc((${customWidth}px - ${breakpoint.padding * 2}px - ${breakpoint.gap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
|
||
height: "24px",
|
||
}}
|
||
>
|
||
{num}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 행 라벨 (좌측) */}
|
||
<div
|
||
className="flex flex-col absolute top-8 left-0"
|
||
style={{
|
||
gap: `${breakpoint.gap}px`,
|
||
paddingTop: `${breakpoint.padding}px`,
|
||
}}
|
||
>
|
||
{gridLabels.rowLabels.map((num) => (
|
||
<div
|
||
key={`row-${num}`}
|
||
className="flex items-center justify-center text-xs font-semibold text-blue-500"
|
||
style={{
|
||
width: "24px",
|
||
height: `${breakpoint.rowHeight}px`,
|
||
}}
|
||
>
|
||
{num}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 디바이스 스크린 */}
|
||
<div
|
||
ref={canvasRef}
|
||
className={cn(
|
||
"relative rounded-lg border-2 bg-white shadow-xl overflow-visible",
|
||
canDrop && isOver && "ring-4 ring-primary/20"
|
||
)}
|
||
style={{
|
||
width: `${customWidth}px`,
|
||
minHeight: `${customHeight}px`,
|
||
marginLeft: "32px",
|
||
marginTop: "32px",
|
||
}}
|
||
>
|
||
{isEmpty ? (
|
||
// 빈 상태
|
||
<div className="flex h-full items-center justify-center p-8">
|
||
<div className="text-center">
|
||
<div className="mb-2 text-sm font-medium text-gray-500">
|
||
컴포넌트를 드래그하여 배치하세요
|
||
</div>
|
||
<div className="text-xs text-gray-400">
|
||
{breakpoint.label} - {breakpoint.columns}칸 그리드
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// 그리드 렌더러
|
||
<PopRenderer
|
||
layout={layout}
|
||
viewportWidth={customWidth}
|
||
currentMode={currentMode}
|
||
isDesignMode={true}
|
||
showGridGuide={showGridGuide}
|
||
selectedComponentId={selectedComponentId}
|
||
onComponentClick={onSelectComponent}
|
||
onBackgroundClick={() => onSelectComponent(null)}
|
||
onComponentMove={onMoveComponent}
|
||
onComponentResize={onResizeComponent}
|
||
onComponentResizeEnd={onResizeEnd}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */}
|
||
{showRightPanel && (
|
||
<div
|
||
className="flex flex-col gap-3"
|
||
style={{ marginTop: "32px" }}
|
||
>
|
||
{/* 초과 컴포넌트 패널 */}
|
||
{showOutOfBoundsPanel && (
|
||
<OutOfBoundsPanel
|
||
components={outOfBoundsComponents}
|
||
selectedComponentId={selectedComponentId}
|
||
onSelectComponent={onSelectComponent}
|
||
onHideComponent={onHideComponent}
|
||
/>
|
||
)}
|
||
|
||
{/* 숨김 컴포넌트 패널 */}
|
||
{showHiddenPanel && (
|
||
<HiddenPanel
|
||
components={hiddenComponents}
|
||
selectedComponentId={selectedComponentId}
|
||
onSelectComponent={onSelectComponent}
|
||
onHideComponent={onHideComponent}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 하단 정보 */}
|
||
<div className="flex items-center justify-between border-t bg-white px-4 py-2">
|
||
<div className="text-xs text-muted-foreground">
|
||
{breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px)
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
Space + 드래그: 패닝 | Ctrl + 휠: 줌
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ========================================
|
||
// 초과 컴포넌트 영역 (오른쪽 패널)
|
||
// ========================================
|
||
|
||
interface OutOfBoundsPanelProps {
|
||
components: PopComponentDefinitionV5[];
|
||
selectedComponentId: string | null;
|
||
onSelectComponent: (id: string | null) => void;
|
||
onHideComponent?: (componentId: string) => void;
|
||
}
|
||
|
||
function OutOfBoundsPanel({
|
||
components,
|
||
selectedComponentId,
|
||
onSelectComponent,
|
||
onHideComponent,
|
||
}: OutOfBoundsPanelProps) {
|
||
return (
|
||
<div
|
||
className="flex flex-col rounded-lg border-2 border-dashed border-orange-300 bg-orange-50/50"
|
||
style={{
|
||
width: "200px",
|
||
maxHeight: "300px",
|
||
}}
|
||
>
|
||
{/* 헤더 */}
|
||
<div className="flex items-center gap-2 border-b border-orange-200 bg-orange-100/50 px-3 py-2 rounded-t-lg">
|
||
<AlertTriangle className="h-4 w-4 text-orange-600" />
|
||
<span className="text-xs font-semibold text-orange-700">
|
||
화면 밖 ({components.length}개)
|
||
</span>
|
||
</div>
|
||
|
||
{/* 컴포넌트 목록 */}
|
||
<div className="flex-1 overflow-auto p-2 space-y-2">
|
||
{components.map((comp) => (
|
||
<OutOfBoundsItem
|
||
key={comp.id}
|
||
component={comp}
|
||
isSelected={selectedComponentId === comp.id}
|
||
onSelect={() => onSelectComponent(comp.id)}
|
||
onHide={() => onHideComponent?.(comp.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* 안내 문구 */}
|
||
<div className="border-t border-orange-200 px-3 py-2 bg-orange-50/80 rounded-b-lg">
|
||
<p className="text-[10px] text-orange-600 leading-tight">
|
||
드래그로 그리드 배치 / 클릭하면 숨김 처리
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ========================================
|
||
// 숨김 컴포넌트 영역 (오른쪽 패널)
|
||
// ========================================
|
||
|
||
interface HiddenPanelProps {
|
||
components: PopComponentDefinitionV5[];
|
||
selectedComponentId: string | null;
|
||
onSelectComponent: (id: string | null) => void;
|
||
onHideComponent?: (componentId: string) => void;
|
||
}
|
||
|
||
function HiddenPanel({
|
||
components,
|
||
selectedComponentId,
|
||
onSelectComponent,
|
||
onHideComponent,
|
||
}: HiddenPanelProps) {
|
||
// 그리드에서 컴포넌트를 드래그하여 이 패널에 드롭하면 숨김 처리
|
||
const [{ isOver, canDrop }, drop] = useDrop(
|
||
() => ({
|
||
accept: DND_ITEM_TYPES.MOVE_COMPONENT,
|
||
drop: (item: { componentId: string; fromHidden?: boolean }) => {
|
||
// 이미 숨김 패널에서 온 아이템은 무시
|
||
if (item.fromHidden) return;
|
||
|
||
// 숨김 처리
|
||
onHideComponent?.(item.componentId);
|
||
toast.info("컴포넌트가 숨김 처리되었습니다");
|
||
},
|
||
canDrop: (item: { componentId: string; fromHidden?: boolean }) => {
|
||
// 숨김 패널에서 온 아이템은 드롭 불가
|
||
return !item.fromHidden;
|
||
},
|
||
collect: (monitor) => ({
|
||
isOver: monitor.isOver(),
|
||
canDrop: monitor.canDrop(),
|
||
}),
|
||
}),
|
||
[onHideComponent]
|
||
);
|
||
|
||
return (
|
||
<div
|
||
ref={drop}
|
||
className={cn(
|
||
"flex flex-col rounded-lg border-2 border-dashed bg-gray-100/50 transition-colors",
|
||
isOver && canDrop
|
||
? "border-gray-600 bg-gray-200/70"
|
||
: "border-gray-400"
|
||
)}
|
||
style={{
|
||
width: "200px",
|
||
maxHeight: "300px",
|
||
}}
|
||
>
|
||
{/* 헤더 */}
|
||
<div className="flex items-center gap-2 border-b border-gray-300 bg-gray-200/50 px-3 py-2 rounded-t-lg">
|
||
<EyeOff className="h-4 w-4 text-gray-600" />
|
||
<span className="text-xs font-semibold text-gray-700">
|
||
숨김 ({components.length}개)
|
||
</span>
|
||
</div>
|
||
|
||
{/* 컴포넌트 목록 */}
|
||
<div className="flex-1 overflow-auto p-2 space-y-2">
|
||
{components.map((comp) => (
|
||
<HiddenItem
|
||
key={comp.id}
|
||
component={comp}
|
||
isSelected={selectedComponentId === comp.id}
|
||
onSelect={() => onSelectComponent(comp.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* 안내 문구 */}
|
||
<div className="border-t border-gray-300 px-3 py-2 bg-gray-100/80 rounded-b-lg">
|
||
<p className="text-[10px] text-gray-600 leading-tight">
|
||
그리드로 드래그하여 다시 표시
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ========================================
|
||
// 초과 컴포넌트 아이템 (드래그 가능)
|
||
// ========================================
|
||
|
||
interface OutOfBoundsItemProps {
|
||
component: PopComponentDefinitionV5;
|
||
isSelected: boolean;
|
||
onSelect: () => void;
|
||
onHide: () => void;
|
||
}
|
||
|
||
function OutOfBoundsItem({
|
||
component,
|
||
isSelected,
|
||
onSelect,
|
||
onHide,
|
||
}: OutOfBoundsItemProps) {
|
||
const [{ isDragging }, drag] = useDrag(
|
||
() => ({
|
||
type: DND_ITEM_TYPES.MOVE_COMPONENT,
|
||
item: {
|
||
componentId: component.id,
|
||
originalPosition: component.position,
|
||
},
|
||
collect: (monitor) => ({
|
||
isDragging: monitor.isDragging(),
|
||
}),
|
||
}),
|
||
[component.id, component.position]
|
||
);
|
||
|
||
// 클릭 시 숨김 처리
|
||
const handleClick = () => {
|
||
onSelect();
|
||
onHide();
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={drag}
|
||
className={cn(
|
||
"rounded-md border-2 bg-white p-2 cursor-move transition-all",
|
||
isSelected
|
||
? "border-primary ring-2 ring-primary/30"
|
||
: "border-gray-300 hover:border-gray-400",
|
||
isDragging && "opacity-50"
|
||
)}
|
||
onClick={handleClick}
|
||
>
|
||
{/* 컴포넌트 이름 */}
|
||
<div className="text-xs font-medium text-gray-700 truncate">
|
||
{component.label || component.type}
|
||
</div>
|
||
|
||
{/* 원본 위치 정보 */}
|
||
<div className="text-[10px] text-gray-500 mt-1">
|
||
원본: {component.position.col}열, {component.position.row}행
|
||
({component.position.colSpan}×{component.position.rowSpan})
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ========================================
|
||
// 숨김 컴포넌트 아이템 (드래그 가능)
|
||
// ========================================
|
||
|
||
interface HiddenItemProps {
|
||
component: PopComponentDefinitionV5;
|
||
isSelected: boolean;
|
||
onSelect: () => void;
|
||
}
|
||
|
||
function HiddenItem({
|
||
component,
|
||
isSelected,
|
||
onSelect,
|
||
}: HiddenItemProps) {
|
||
const [{ isDragging }, drag] = useDrag(
|
||
() => ({
|
||
type: DND_ITEM_TYPES.MOVE_COMPONENT,
|
||
item: {
|
||
componentId: component.id,
|
||
originalPosition: component.position,
|
||
fromHidden: true, // 숨김 패널에서 왔음을 표시
|
||
},
|
||
collect: (monitor) => ({
|
||
isDragging: monitor.isDragging(),
|
||
}),
|
||
}),
|
||
[component.id, component.position]
|
||
);
|
||
|
||
return (
|
||
<div
|
||
ref={drag}
|
||
className={cn(
|
||
"rounded-md border-2 bg-white p-2 cursor-move transition-all opacity-60",
|
||
isSelected
|
||
? "border-primary ring-2 ring-primary/30"
|
||
: "border-gray-400 hover:border-gray-500",
|
||
isDragging && "opacity-30"
|
||
)}
|
||
onClick={onSelect}
|
||
>
|
||
{/* 컴포넌트 이름 */}
|
||
<div className="flex items-center gap-1 text-xs font-medium text-gray-600 truncate">
|
||
<EyeOff className="h-3 w-3" />
|
||
{component.label || component.type}
|
||
</div>
|
||
|
||
{/* 원본 위치 정보 */}
|
||
<div className="text-[10px] text-gray-500 mt-1">
|
||
원본: {component.position.col}열, {component.position.row}행
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|