ERP-node/frontend/components/pop/designer/PopCanvas.tsx

432 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<PopComponentDefinitionV5>) => 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<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// 드래그 상태
const [isDraggingComponent, setIsDraggingComponent] = useState(false);
const [draggedComponentId, setDraggedComponentId] = useState<string | null>(null);
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
const [dragPreviewPos, setDragPreviewPos] = useState<PopGridPosition | null>(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 (
<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" />
{/* 해상도 표시 */}
<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"
style={{
width: `${customWidth + 32}px`, // 라벨 공간 추가
minHeight: `${customHeight + 32}px`,
transform: `scale(${canvasScale})`,
}}
>
{/* 그리드 라벨 영역 */}
{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-hidden",
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)}
/>
)}
</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>
);
}