"use client"; import { useCallback, useMemo, useRef, useState, useEffect } from "react"; import { useDrop } from "react-dnd"; import GridLayout, { Layout } from "react-grid-layout"; import { cn } from "@/lib/utils"; import { PopLayoutDataV2, PopLayoutModeKey, PopComponentType, GridPosition, MODE_RESOLUTIONS, } from "./types/pop-layout"; import { DND_ITEM_TYPES, DragItemSection } from "./panels/PopPanel"; import { GripVertical, Trash2, ZoomIn, ZoomOut, Maximize2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SectionGridV2 } from "./SectionGridV2"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; // ======================================== // 타입 정의 // ======================================== type DeviceType = "mobile" | "tablet"; // 모드별 라벨 const MODE_LABELS: Record = { tablet_landscape: "태블릿 가로", tablet_portrait: "태블릿 세로", mobile_landscape: "모바일 가로", mobile_portrait: "모바일 세로", }; // ======================================== // Props // ======================================== interface PopCanvasProps { layout: PopLayoutDataV2; activeDevice: DeviceType; activeModeKey: PopLayoutModeKey; onModeKeyChange: (modeKey: PopLayoutModeKey) => void; selectedSectionId: string | null; selectedComponentId: string | null; onSelectSection: (id: string | null) => void; onSelectComponent: (id: string | null) => void; onUpdateSectionPosition: (sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; onDeleteSection: (id: string) => void; onDropSection: (gridPosition: GridPosition) => void; onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; onDeleteComponent: (sectionId: string, componentId: string) => void; } // ======================================== // 메인 컴포넌트 // ======================================== export function PopCanvas({ layout, activeDevice, activeModeKey, onModeKeyChange, selectedSectionId, selectedComponentId, onSelectSection, onSelectComponent, onUpdateSectionPosition, onUpdateComponentPosition, onDeleteSection, onDropSection, onDropComponent, onDeleteComponent, }: PopCanvasProps) { const { settings, sections, components, layouts } = layout; const canvasGrid = settings.canvasGrid; // 줌 상태 (0.3 ~ 1.0 범위) const [canvasScale, setCanvasScale] = useState(0.6); // 패닝 상태 const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [isSpacePressed, setIsSpacePressed] = useState(false); // Space 키 눌림 상태 const containerRef = useRef(null); // 줌 인 (최대 1.5로 증가) const handleZoomIn = () => { setCanvasScale((prev) => Math.min(1.5, prev + 0.1)); }; // 줌 아웃 (최소 0.3) const handleZoomOut = () => { setCanvasScale((prev) => Math.max(0.3, prev - 0.1)); }; // 맞춤 (1.0) const handleZoomFit = () => { setCanvasScale(1.0); }; // 패닝 시작 (중앙 마우스 버튼 또는 배경 영역 드래그) const handlePanStart = (e: React.MouseEvent) => { // 중앙 마우스 버튼(휠 버튼, button === 1) 또는 Space 키 누른 상태 // 또는 내부 컨테이너(스크롤 영역) 직접 클릭 시 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); }; // 마우스 휠 줌 (0.3 ~ 1.5 범위) 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; // 약간의 딜레이 후 중앙으로 스크롤 (DOM이 완전히 렌더링된 후) 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"; // 단일 캔버스 프레임 렌더링 const renderDeviceFrame = (modeKey: PopLayoutModeKey) => { const resolution = MODE_RESOLUTIONS[modeKey]; const isActive = modeKey === activeModeKey; const modeLayout = layouts[modeKey]; // 이 모드의 섹션 위치 목록 const sectionPositions = modeLayout.sectionPositions; const sectionIds = Object.keys(sectionPositions); // GridLayout용 레이아웃 아이템 생성 const gridLayoutItems: Layout[] = sectionIds.map((sectionId) => { const pos = sectionPositions[sectionId]; return { i: sectionId, x: pos.col - 1, y: pos.row - 1, w: pos.colSpan, h: pos.rowSpan, minW: 2, minH: 1, }; }); const cols = canvasGrid.columns; const rowHeight = canvasGrid.rowHeight; const margin: [number, number] = [canvasGrid.gap, canvasGrid.gap]; const sizeLabel = `${resolution.width}x${resolution.height}`; const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`; // 드래그/리사이즈 완료 핸들러 const handleDragResizeStop = ( layoutItems: Layout[], oldItem: Layout, newItem: Layout ) => { const newPos: GridPosition = { col: newItem.x + 1, row: newItem.y + 1, colSpan: newItem.w, rowSpan: newItem.h, }; onUpdateSectionPosition(newItem.i, newPos, modeKey); }; return (
{ if (!isActive) { onModeKeyChange(modeKey); } }} > {/* 모드 라벨 */}
{modeLabel}
{/* 활성 표시 배지 */} {isActive && (
편집 중
)} {/* 드롭 영역 */} onUpdateComponentPosition(compId, pos, modeKey)} onDeleteSection={onDeleteSection} onDeleteComponent={onDeleteComponent} />
); }; return (
{/* 줌 컨트롤 바 */}
줌: {Math.round(canvasScale * 100)}%
{/* 캔버스 영역 (패닝 가능) */}
{/* 스크롤 가능한 큰 영역 - 빈 공간 클릭 시 패닝 가능 */}
{/* 가로 모드 캔버스 */} {renderDeviceFrame(landscapeModeKey)} {/* 세로 모드 캔버스 */} {renderDeviceFrame(portraitModeKey)}
); } // ======================================== // 캔버스 드롭 영역 컴포넌트 // ======================================== interface CanvasDropZoneProps { modeKey: PopLayoutModeKey; isActive: boolean; resolution: { width: number; height: number }; scale: number; cols: number; rowHeight: number; margin: [number, number]; sections: PopLayoutDataV2["sections"]; components: PopLayoutDataV2["components"]; sectionPositions: Record; componentPositions: Record; gridLayoutItems: Layout[]; selectedSectionId: string | null; selectedComponentId: string | null; onSelectSection: (id: string | null) => void; onSelectComponent: (id: string | null) => void; onDragResizeStop: (layout: Layout[], oldItem: Layout, newItem: Layout) => void; onDropSection: (gridPosition: GridPosition) => void; onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; onUpdateComponentPosition: (componentId: string, position: GridPosition) => void; onDeleteSection: (id: string) => void; onDeleteComponent: (sectionId: string, componentId: string) => void; } function CanvasDropZone({ modeKey, isActive, resolution, scale, cols, rowHeight, margin, sections, components, sectionPositions, componentPositions, gridLayoutItems, selectedSectionId, selectedComponentId, onSelectSection, onSelectComponent, onDragResizeStop, onDropSection, onDropComponent, onUpdateComponentPosition, onDeleteSection, onDeleteComponent, }: CanvasDropZoneProps) { const dropRef = useRef(null); // 스케일 적용된 크기 const scaledWidth = resolution.width * scale; const scaledHeight = resolution.height * scale; // 섹션 드롭 핸들러 const [{ isOver, canDrop }, drop] = useDrop( () => ({ accept: DND_ITEM_TYPES.SECTION, drop: (item: DragItemSection, monitor) => { if (!isActive) return; const clientOffset = monitor.getClientOffset(); if (!clientOffset || !dropRef.current) return; const dropRect = dropRef.current.getBoundingClientRect(); // 스케일 보정 const x = (clientOffset.x - dropRect.left) / scale; const y = (clientOffset.y - dropRect.top) / scale; // 그리드 위치 계산 const colWidth = (resolution.width - 16) / cols; const col = Math.max(1, Math.min(cols, Math.floor(x / colWidth) + 1)); const row = Math.max(1, Math.floor(y / (rowHeight * scale)) + 1); onDropSection({ col, row, colSpan: 3, rowSpan: 4, }); }, canDrop: () => isActive, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }), [isActive, resolution, scale, cols, rowHeight, onDropSection] ); drop(dropRef); const sectionIds = Object.keys(sectionPositions); return (
{ if (e.target === e.currentTarget) { onSelectSection(null); onSelectComponent(null); } }} > {sectionIds.length > 0 ? ( {sectionIds.map((sectionId) => { const sectionDef = sections[sectionId]; if (!sectionDef) return null; return (
{ e.stopPropagation(); onSelectSection(sectionId); }} > {/* 섹션 헤더 */}
{sectionDef.label || "섹션"}
{selectedSectionId === sectionId && isActive && ( )}
{/* 섹션 내부 - 컴포넌트들 */}
); })}
) : (
{isOver && canDrop ? "여기에 섹션을 놓으세요" : isActive ? "왼쪽 패널에서 섹션을 드래그하세요" : "클릭하여 편집 모드로 전환"}
)}
); }