"use client"; import { useCallback, useRef, useState, useEffect } from "react"; import { useDrop } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopLayoutDataV3, PopLayoutModeKey, PopComponentType, GridPosition, MODE_RESOLUTIONS, } from "./types/pop-layout"; import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react"; import { Button } from "@/components/ui/button"; // ======================================== // 타입 정의 // ======================================== type DeviceType = "mobile" | "tablet"; // 모드별 라벨 const MODE_LABELS: Record = { tablet_landscape: "태블릿 가로", tablet_portrait: "태블릿 세로", mobile_landscape: "모바일 가로", mobile_portrait: "모바일 세로", }; // 컴포넌트 타입별 라벨 const COMPONENT_TYPE_LABELS: Record = { "pop-field": "필드", "pop-button": "버튼", "pop-list": "리스트", "pop-indicator": "인디케이터", "pop-scanner": "스캐너", "pop-numpad": "숫자패드", }; // ======================================== // Props // ======================================== interface PopCanvasProps { layout: PopLayoutDataV3; activeDevice: DeviceType; activeModeKey: PopLayoutModeKey; onModeKeyChange: (modeKey: PopLayoutModeKey) => void; selectedComponentId: string | null; onSelectComponent: (id: string | null) => void; onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void; onDeleteComponent: (componentId: string) => void; } // ======================================== // 메인 컴포넌트 // ======================================== export function PopCanvas({ layout, activeDevice, activeModeKey, onModeKeyChange, selectedComponentId, onSelectComponent, onUpdateComponentPosition, onDropComponent, onDeleteComponent, }: PopCanvasProps) { const { settings, components, layouts } = layout; const canvasGrid = settings.canvasGrid; // 줌 상태 (0.3 ~ 1.5 범위) const [canvasScale, setCanvasScale] = useState(0.6); // 패닝 상태 const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [isSpacePressed, setIsSpacePressed] = useState(false); const containerRef = useRef(null); // 줌 컨트롤 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 handlePanStart = (e: React.MouseEvent) => { const isMiddleButton = e.button === 1; const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area"); if (isMiddleButton || isSpacePressed || isScrollAreaClick) { 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); // 마우스 휠 줌 const handleWheel = useCallback((e: React.WheelEvent) => { 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]); // 초기 로드 시 캔버스 중앙 스크롤 useEffect(() => { if (containerRef.current) { const container = containerRef.current; const timer = setTimeout(() => { const scrollX = (container.scrollWidth - container.clientWidth) / 2; const scrollY = (container.scrollHeight - container.clientHeight) / 2; container.scrollTo(scrollX, scrollY); }, 100); return () => clearTimeout(timer); } }, [activeDevice]); // 현재 디바이스의 가로/세로 모드 키 const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet" ? "tablet_landscape" : "mobile_landscape"; const portraitModeKey: PopLayoutModeKey = activeDevice === "tablet" ? "tablet_portrait" : "mobile_portrait"; return (
{/* 줌 컨트롤 바 */}
줌: {Math.round(canvasScale * 100)}%
{/* 캔버스 영역 */}
{/* 가로 모드 */} {/* 세로 모드 */}
); } // ======================================== // CSS Grid 기반 디바이스 프레임 (v3: 컴포넌트 직접 배치) // ======================================== interface DeviceFrameProps { modeKey: PopLayoutModeKey; isActive: boolean; scale: number; canvasGrid: { columns: number; rows: number; gap: number }; layout: PopLayoutDataV3; selectedComponentId: string | null; onModeKeyChange: (modeKey: PopLayoutModeKey) => void; onSelectComponent: (id: string | null) => void; onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void; onDeleteComponent: (componentId: string) => void; } function DeviceFrame({ modeKey, isActive, scale, canvasGrid, layout, selectedComponentId, onModeKeyChange, onSelectComponent, onUpdateComponentPosition, onDropComponent, onDeleteComponent, }: DeviceFrameProps) { const gridRef = useRef(null); const dropRef = useRef(null); const { components, layouts } = layout; const resolution = MODE_RESOLUTIONS[modeKey]; const modeLayout = layouts[modeKey]; const componentPositions = modeLayout.componentPositions; const componentIds = Object.keys(componentPositions); const cols = canvasGrid.columns; const rows = canvasGrid.rows || 24; const gap = canvasGrid.gap; // 드래그 상태 const [dragState, setDragState] = useState<{ componentId: string; startPos: GridPosition; currentPos: GridPosition; isDragging: boolean; } | null>(null); // 리사이즈 상태 const [resizeState, setResizeState] = useState<{ componentId: string; startPos: GridPosition; currentPos: GridPosition; handle: "se" | "sw" | "ne" | "nw" | "e" | "w" | "n" | "s"; isResizing: boolean; } | null>(null); // 라벨 const sizeLabel = `${resolution.width}x${resolution.height}`; const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`; // 마우스 → 그리드 좌표 변환 const getGridPosition = useCallback((clientX: number, clientY: number): { col: number; row: number } => { if (!gridRef.current) return { col: 1, row: 1 }; const rect = gridRef.current.getBoundingClientRect(); const x = (clientX - rect.left) / scale; const y = (clientY - rect.top) / scale; const cellWidth = (resolution.width - gap * (cols + 1)) / cols; const cellHeight = (resolution.height - gap * (rows + 1)) / rows; const col = Math.max(1, Math.min(cols, Math.floor((x - gap) / (cellWidth + gap)) + 1)); const row = Math.max(1, Math.min(rows, Math.floor((y - gap) / (cellHeight + gap)) + 1)); return { col, row }; }, [scale, resolution, cols, rows, gap]); // 드래그 시작 const handleDragStart = useCallback((e: React.MouseEvent, componentId: string) => { if (!isActive) return; e.preventDefault(); e.stopPropagation(); const pos = componentPositions[componentId]; setDragState({ componentId, startPos: { ...pos }, currentPos: { ...pos }, isDragging: true, }); }, [isActive, componentPositions]); // 마우스 이동 const handleMouseMove = useCallback((e: React.MouseEvent) => { if (dragState?.isDragging && gridRef.current) { const { col, row } = getGridPosition(e.clientX, e.clientY); const newCol = Math.max(1, Math.min(cols - dragState.startPos.colSpan + 1, col)); const newRow = Math.max(1, Math.min(rows - dragState.startPos.rowSpan + 1, row)); setDragState(prev => prev ? { ...prev, currentPos: { ...prev.startPos, col: newCol, row: newRow } } : null); } if (resizeState?.isResizing && gridRef.current) { const { col, row } = getGridPosition(e.clientX, e.clientY); const startPos = resizeState.startPos; let newPos = { ...startPos }; switch (resizeState.handle) { case "se": newPos.colSpan = Math.max(2, col - startPos.col + 1); newPos.rowSpan = Math.max(2, row - startPos.row + 1); break; case "e": newPos.colSpan = Math.max(2, col - startPos.col + 1); break; case "s": newPos.rowSpan = Math.max(2, row - startPos.row + 1); break; case "sw": const newColSW = Math.min(col, startPos.col + startPos.colSpan - 2); newPos.col = newColSW; newPos.colSpan = startPos.col + startPos.colSpan - newColSW; newPos.rowSpan = Math.max(2, row - startPos.row + 1); break; case "w": const newColW = Math.min(col, startPos.col + startPos.colSpan - 2); newPos.col = newColW; newPos.colSpan = startPos.col + startPos.colSpan - newColW; break; case "ne": const newRowNE = Math.min(row, startPos.row + startPos.rowSpan - 2); newPos.row = newRowNE; newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNE; newPos.colSpan = Math.max(2, col - startPos.col + 1); break; case "n": const newRowN = Math.min(row, startPos.row + startPos.rowSpan - 2); newPos.row = newRowN; newPos.rowSpan = startPos.row + startPos.rowSpan - newRowN; break; case "nw": const newColNW = Math.min(col, startPos.col + startPos.colSpan - 2); const newRowNW = Math.min(row, startPos.row + startPos.rowSpan - 2); newPos.col = newColNW; newPos.row = newRowNW; newPos.colSpan = startPos.col + startPos.colSpan - newColNW; newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNW; break; } newPos.col = Math.max(1, newPos.col); newPos.row = Math.max(1, newPos.row); newPos.colSpan = Math.min(cols - newPos.col + 1, newPos.colSpan); newPos.rowSpan = Math.min(rows - newPos.row + 1, newPos.rowSpan); setResizeState(prev => prev ? { ...prev, currentPos: newPos } : null); } }, [dragState, resizeState, getGridPosition, cols, rows]); // 드래그/리사이즈 종료 const handleMouseUp = useCallback(() => { if (dragState?.isDragging) { onUpdateComponentPosition(dragState.componentId, dragState.currentPos, modeKey); setDragState(null); } if (resizeState?.isResizing) { onUpdateComponentPosition(resizeState.componentId, resizeState.currentPos, modeKey); setResizeState(null); } }, [dragState, resizeState, onUpdateComponentPosition, modeKey]); // 리사이즈 시작 const handleResizeStart = useCallback((e: React.MouseEvent, componentId: string, handle: string) => { if (!isActive) return; e.preventDefault(); e.stopPropagation(); const pos = componentPositions[componentId]; setResizeState({ componentId, startPos: { ...pos }, currentPos: { ...pos }, handle: handle as any, isResizing: true, }); }, [isActive, componentPositions]); // 컴포넌트 드롭 const [{ isOver, canDrop }, drop] = useDrop( () => ({ accept: DND_ITEM_TYPES.COMPONENT, drop: (item: DragItemComponent, monitor) => { if (!isActive) return; const clientOffset = monitor.getClientOffset(); if (!clientOffset || !gridRef.current) return; const { col, row } = getGridPosition(clientOffset.x, clientOffset.y); onDropComponent(item.componentType, { col, row, colSpan: 4, rowSpan: 3 }); }, canDrop: () => isActive, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }), [isActive, getGridPosition, onDropComponent] ); drop(dropRef); // 현재 표시할 위치 const getDisplayPosition = (componentId: string): GridPosition => { if (dragState?.componentId === componentId && dragState.isDragging) { return dragState.currentPos; } if (resizeState?.componentId === componentId && resizeState.isResizing) { return resizeState.currentPos; } return componentPositions[componentId]; }; return (
{/* 모드 라벨 */}
{modeLabel}
{/* 디바이스 프레임 */}
{ if (e.target === e.currentTarget) { if (!isActive) onModeKeyChange(modeKey); else onSelectComponent(null); } }} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} > {/* CSS Grid (뷰어와 동일) */}
{componentIds.length > 0 ? ( componentIds.map((componentId) => { const compDef = components[componentId]; if (!compDef) return null; const pos = getDisplayPosition(componentId); const isSelected = selectedComponentId === componentId; const isDragging = dragState?.componentId === componentId && dragState.isDragging; const isResizing = resizeState?.componentId === componentId && resizeState.isResizing; return (
{ e.stopPropagation(); if (!isActive) onModeKeyChange(modeKey); onSelectComponent(componentId); }} onMouseDown={(e) => handleDragStart(e, componentId)} > {/* 컴포넌트 라벨 */} {compDef.label || COMPONENT_TYPE_LABELS[compDef.type]} {/* 리사이즈 핸들 */} {isActive && isSelected && ( <>
handleResizeStart(e, componentId, "se")} />
handleResizeStart(e, componentId, "sw")} />
handleResizeStart(e, componentId, "ne")} />
handleResizeStart(e, componentId, "nw")} />
handleResizeStart(e, componentId, "e")} />
handleResizeStart(e, componentId, "w")} />
handleResizeStart(e, componentId, "s")} />
handleResizeStart(e, componentId, "n")} /> )}
); }) ) : (
{isOver && canDrop ? "여기에 컴포넌트를 놓으세요" : isActive ? "왼쪽 패널에서 컴포넌트를 드래그하세요" : "클릭하여 편집"}
)}
); }