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

563 lines
21 KiB
TypeScript
Raw Normal View History

"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<PopLayoutModeKey, string> = {
tablet_landscape: "태블릿 가로",
tablet_portrait: "태블릿 세로",
mobile_landscape: "모바일 가로",
mobile_portrait: "모바일 세로",
};
// 컴포넌트 타입별 라벨
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"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<HTMLDivElement>(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 (
<div className="relative flex h-full flex-col bg-gray-50">
{/* 줌 컨트롤 바 */}
<div className="flex shrink-0 items-center justify-end gap-2 border-b bg-white px-4 py-2">
<span className="text-xs text-muted-foreground">
: {Math.round(canvasScale * 100)}%
</span>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut} title="줌 아웃">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn} title="줌 인">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit} title="맞춤 (100%)">
<Maximize2 className="h-4 w-4" />
</Button>
</div>
{/* 캔버스 영역 */}
<div
ref={containerRef}
className={cn(
"relative flex-1 overflow-auto",
isPanning && "cursor-grabbing",
isSpacePressed && "cursor-grab"
)}
onMouseDown={handlePanStart}
onMouseMove={handlePanMove}
onMouseUp={handlePanEnd}
onMouseLeave={handlePanEnd}
onWheel={handleWheel}
>
<div
className="canvas-scroll-area flex items-center justify-center gap-16"
style={{ padding: "500px", minWidth: "fit-content", minHeight: "fit-content" }}
>
{/* 가로 모드 */}
<DeviceFrame
modeKey={landscapeModeKey}
isActive={landscapeModeKey === activeModeKey}
scale={canvasScale}
canvasGrid={canvasGrid}
layout={layout}
selectedComponentId={selectedComponentId}
onModeKeyChange={onModeKeyChange}
onSelectComponent={onSelectComponent}
onUpdateComponentPosition={onUpdateComponentPosition}
onDropComponent={onDropComponent}
onDeleteComponent={onDeleteComponent}
/>
{/* 세로 모드 */}
<DeviceFrame
modeKey={portraitModeKey}
isActive={portraitModeKey === activeModeKey}
scale={canvasScale}
canvasGrid={canvasGrid}
layout={layout}
selectedComponentId={selectedComponentId}
onModeKeyChange={onModeKeyChange}
onSelectComponent={onSelectComponent}
onUpdateComponentPosition={onUpdateComponentPosition}
onDropComponent={onDropComponent}
onDeleteComponent={onDeleteComponent}
/>
</div>
</div>
</div>
);
}
// ========================================
// 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<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(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 (
<div className="relative shrink-0">
{/* 모드 라벨 */}
<div
className={cn(
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
isActive ? "text-primary" : "text-muted-foreground"
)}
>
{modeLabel}
</div>
{/* 디바이스 프레임 */}
<div
ref={dropRef}
className={cn(
"relative cursor-pointer overflow-hidden rounded-xl bg-white shadow-lg transition-all",
isActive ? "ring-2 ring-primary ring-offset-2" : "ring-1 ring-gray-200 hover:ring-gray-300",
isOver && canDrop && "ring-2 ring-primary bg-primary/5"
)}
style={{
width: resolution.width * scale,
height: resolution.height * scale,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
if (!isActive) onModeKeyChange(modeKey);
else onSelectComponent(null);
}
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* CSS Grid (뷰어와 동일) */}
<div
ref={gridRef}
className="origin-top-left"
style={{
transform: `scale(${scale})`,
width: resolution.width,
height: resolution.height,
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: `${gap}px`,
padding: `${gap}px`,
}}
>
{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 (
<div
key={componentId}
className={cn(
"group relative flex cursor-move items-center justify-center overflow-hidden rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-gray-300",
(isDragging || isResizing) && "opacity-80 shadow-xl z-50"
)}
style={{
gridColumn: `${pos.col} / span ${pos.colSpan}`,
gridRow: `${pos.row} / span ${pos.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
if (!isActive) onModeKeyChange(modeKey);
onSelectComponent(componentId);
}}
onMouseDown={(e) => handleDragStart(e, componentId)}
>
{/* 컴포넌트 라벨 */}
<span className="text-xs text-gray-500 select-none">
{compDef.label || COMPONENT_TYPE_LABELS[compDef.type]}
</span>
{/* 리사이즈 핸들 */}
{isActive && isSelected && (
<>
<div className="absolute -right-1 -bottom-1 h-3 w-3 cursor-se-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "se")} />
<div className="absolute -left-1 -bottom-1 h-3 w-3 cursor-sw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "sw")} />
<div className="absolute -right-1 -top-1 h-3 w-3 cursor-ne-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "ne")} />
<div className="absolute -left-1 -top-1 h-3 w-3 cursor-nw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "nw")} />
<div className="absolute right-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-e-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "e")} />
<div className="absolute left-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-w-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "w")} />
<div className="absolute bottom-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-s-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "s")} />
<div className="absolute top-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-n-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "n")} />
</>
)}
</div>
);
})
) : (
<div
className={cn(
"col-span-full row-span-full flex items-center justify-center text-sm",
isOver && canDrop ? "text-primary" : "text-gray-400"
)}
>
{isOver && canDrop
? "여기에 컴포넌트를 놓으세요"
: isActive
? "왼쪽 패널에서 컴포넌트를 드래그하세요"
: "클릭하여 편집"}
</div>
)}
</div>
</div>
</div>
);
}