"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 } from "lucide-react"; import { Button } from "@/components/ui/button"; import PopRenderer from "./renderers/PopRenderer"; import { mouseToGridPosition, findNextEmptyPosition } from "./utils/gridUtils"; // DnD 타입 상수 (인라인) const DND_ITEM_TYPES = { COMPONENT: "component", } as const; interface DragItemComponent { type: typeof DND_ITEM_TYPES.COMPONENT; componentType: PopComponentType; } // ======================================== // 프리셋 해상도 (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) => void; onDeleteComponent: (componentId: string) => void; onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void; onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void; } // ======================================== // PopCanvas: 그리드 캔버스 // ======================================== export default function PopCanvas({ layout, selectedComponentId, currentMode, onModeChange, onSelectComponent, onDropComponent, onUpdateComponent, onDeleteComponent, onMoveComponent, onResizeComponent, }: 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(null); const canvasRef = useRef(null); // 드래그 상태 const [isDraggingComponent, setIsDraggingComponent] = useState(false); const [draggedComponentId, setDraggedComponentId] = useState(null); const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null); const [dragPreviewPos, setDragPreviewPos] = useState(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, drop: (item: DragItemComponent, monitor) => { if (!canvasRef.current) return; const offset = monitor.getClientOffset(); if (!offset) return; const canvasRect = canvasRef.current.getBoundingClientRect(); // 마우스 위치 → 그리드 좌표 변환 const gridPos = mouseToGridPosition( offset.x, offset.y, canvasRect, breakpoint.columns, breakpoint.rowHeight, breakpoint.gap, breakpoint.padding ); // 컴포넌트 기본 크기 const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[item.componentType]; // 다음 빈 위치 찾기 const existingPositions = Object.values(layout.components).map(c => c.position); const position = findNextEmptyPosition( existingPositions, defaultSize.colSpan, defaultSize.rowSpan, breakpoint.columns ); // 컴포넌트 추가 onDropComponent(item.componentType, position); }, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }), [onDropComponent, breakpoint, layout.components] ); drop(canvasRef); // 빈 상태 체크 const isEmpty = Object.keys(layout.components).length === 0; return (
{/* 상단 컨트롤 */}
{/* 모드 프리셋 버튼 */}
{VIEWPORT_PRESETS.map((preset) => { const Icon = preset.icon; const isActive = currentMode === preset.id; const isDefault = preset.id === DEFAULT_PRESET; return ( ); })}
{/* 해상도 표시 */}
{customWidth} × {customHeight}
{/* 줌 컨트롤 */}
{Math.round(canvasScale * 100)}%
{/* 그리드 가이드 토글 */}
{/* 캔버스 영역 */}
{/* 그리드 라벨 영역 */} {showGridGuide && ( <> {/* 열 라벨 (상단) */}
{gridLabels.columnLabels.map((num) => (
{num}
))}
{/* 행 라벨 (좌측) */}
{gridLabels.rowLabels.map((num) => (
{num}
))}
)} {/* 디바이스 스크린 */}
{isEmpty ? ( // 빈 상태
컴포넌트를 드래그하여 배치하세요
{breakpoint.label} - {breakpoint.columns}칸 그리드
) : ( // 그리드 렌더러 onSelectComponent(null)} /> )}
{/* 하단 정보 */}
{breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px)
Space + 드래그: 패닝 | Ctrl + 휠: 줌
); }