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

379 lines
12 KiB
TypeScript

"use client";
import { useCallback, useMemo, useRef } from "react";
import { useDrop } from "react-dnd";
import GridLayout, { Layout } from "react-grid-layout";
import { cn } from "@/lib/utils";
import {
PopLayoutData,
PopSectionData,
PopComponentData,
PopComponentType,
GridPosition,
} from "./types/pop-layout";
import { DND_ITEM_TYPES, DragItemSection, DragItemComponent } from "./panels/PopPanel";
import { GripVertical, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SectionGrid } from "./SectionGrid";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
type DeviceType = "mobile" | "tablet";
// 디바이스별 캔버스 크기 (dp)
const DEVICE_SIZES = {
mobile: {
portrait: { width: 360, height: 640 },
landscape: { width: 640, height: 360 },
},
tablet: {
portrait: { width: 768, height: 1024 },
landscape: { width: 1024, height: 768 },
},
} as const;
interface PopCanvasProps {
layout: PopLayoutData;
activeDevice: DeviceType;
showBothDevices: boolean;
isLandscape: boolean;
selectedSectionId: string | null;
selectedComponentId: string | null;
onSelectSection: (id: string | null) => void;
onSelectComponent: (id: string | null) => void;
onUpdateSection: (id: string, updates: Partial<PopSectionData>) => void;
onDeleteSection: (id: string) => void;
onLayoutChange: (sections: PopSectionData[]) => void;
onDropSection: (gridPosition: GridPosition) => void;
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
onUpdateComponent: (sectionId: string, componentId: string, updates: Partial<PopComponentData>) => void;
onDeleteComponent: (sectionId: string, componentId: string) => void;
}
export function PopCanvas({
layout,
activeDevice,
showBothDevices,
isLandscape,
selectedSectionId,
selectedComponentId,
onSelectSection,
onSelectComponent,
onUpdateSection,
onDeleteSection,
onLayoutChange,
onDropSection,
onDropComponent,
onUpdateComponent,
onDeleteComponent,
}: PopCanvasProps) {
const { canvasGrid, sections } = layout;
// GridLayout용 레이아웃 변환
const gridLayoutItems: Layout[] = useMemo(() => {
return sections.map((section) => ({
i: section.id,
x: section.grid.col - 1,
y: section.grid.row - 1,
w: section.grid.colSpan,
h: section.grid.rowSpan,
minW: 2, // 최소 너비 2칸
minH: 1, // 최소 높이 1행 (20px) - 헤더만 보임
}));
}, [sections]);
// 드래그/리사이즈 완료 핸들러 (onDragStop, onResizeStop 사용)
const handleDragResizeStop = useCallback(
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
const section = sections.find((s) => s.id === newItem.i);
if (!section) return;
const newGrid: GridPosition = {
col: newItem.x + 1,
row: newItem.y + 1,
colSpan: newItem.w,
rowSpan: newItem.h,
};
// 변경된 경우에만 업데이트
if (
section.grid.col !== newGrid.col ||
section.grid.row !== newGrid.row ||
section.grid.colSpan !== newGrid.colSpan ||
section.grid.rowSpan !== newGrid.rowSpan
) {
const updatedSections = sections.map((s) =>
s.id === newItem.i ? { ...s, grid: newGrid } : s
);
onLayoutChange(updatedSections);
}
},
[sections, onLayoutChange]
);
// 디바이스 프레임 렌더링
const renderDeviceFrame = (device: DeviceType) => {
const orientation = isLandscape ? "landscape" : "portrait";
const size = DEVICE_SIZES[device][orientation];
const isActive = device === activeDevice;
const cols = canvasGrid.columns;
const rowHeight = canvasGrid.rowHeight;
const margin: [number, number] = [canvasGrid.gap, canvasGrid.gap];
const sizeLabel = `${size.width}x${size.height}`;
const deviceLabel =
device === "mobile" ? `모바일 (${sizeLabel})` : `태블릿 (${sizeLabel})`;
return (
<div
className={cn(
"relative rounded-[2rem] border-4 bg-white shadow-xl transition-all",
isActive ? "border-primary" : "border-gray-300",
device === "mobile" ? "rounded-[1.5rem]" : "rounded-[2rem]"
)}
style={{
width: size.width,
height: size.height,
}}
>
{/* 디바이스 라벨 */}
<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"
)}
>
{deviceLabel}
</div>
{/* 드롭 영역 */}
<CanvasDropZone
device={device}
isActive={isActive}
size={size}
cols={cols}
rowHeight={rowHeight}
margin={margin}
sections={sections}
gridLayoutItems={gridLayoutItems}
selectedSectionId={selectedSectionId}
selectedComponentId={selectedComponentId}
onSelectSection={onSelectSection}
onSelectComponent={onSelectComponent}
onDragResizeStop={handleDragResizeStop}
onDropSection={onDropSection}
onDropComponent={onDropComponent}
onDeleteSection={onDeleteSection}
onUpdateComponent={onUpdateComponent}
onDeleteComponent={onDeleteComponent}
/>
</div>
);
};
return (
<div className="flex h-full items-center justify-center gap-8 overflow-auto bg-gray-50 p-8">
{showBothDevices ? (
<>
{renderDeviceFrame("tablet")}
{renderDeviceFrame("mobile")}
</>
) : (
renderDeviceFrame(activeDevice)
)}
</div>
);
}
// 캔버스 드롭 영역
interface CanvasDropZoneProps {
device: DeviceType;
isActive: boolean;
size: { width: number; height: number };
cols: number;
rowHeight: number;
margin: [number, number];
sections: PopSectionData[];
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;
onDeleteSection: (id: string) => void;
onUpdateComponent: (sectionId: string, componentId: string, updates: Partial<PopComponentData>) => void;
onDeleteComponent: (sectionId: string, componentId: string) => void;
}
function CanvasDropZone({
device,
isActive,
size,
cols,
rowHeight,
margin,
sections,
gridLayoutItems,
selectedSectionId,
selectedComponentId,
onSelectSection,
onSelectComponent,
onDragResizeStop,
onDropSection,
onDropComponent,
onDeleteSection,
onUpdateComponent,
onDeleteComponent,
}: CanvasDropZoneProps) {
const dropRef = useRef<HTMLDivElement>(null);
// 섹션 드롭 핸들러
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;
const y = clientOffset.y - dropRect.top;
// 그리드 위치 계산
const colWidth = (size.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) + 1);
onDropSection({
col,
row,
colSpan: 3, // 기본 너비
rowSpan: 4, // 기본 높이 (20px * 4 = 80px)
});
},
canDrop: () => isActive,
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}), [isActive, size, cols, rowHeight, onDropSection]);
// ref 결합
drop(dropRef);
return (
<div
ref={dropRef}
className={cn(
"h-full w-full overflow-auto rounded-[1.5rem] bg-gray-100 p-2 transition-colors",
isOver && canDrop && "bg-primary/10 ring-2 ring-primary ring-inset"
)}
onClick={(e) => {
if (e.target === e.currentTarget) {
onSelectSection(null);
onSelectComponent(null);
}
}}
>
{sections.length > 0 ? (
<GridLayout
className="layout"
layout={gridLayoutItems}
cols={cols}
rowHeight={rowHeight}
width={size.width - 16}
margin={margin}
containerPadding={[0, 0]}
onDragStop={onDragResizeStop}
onResizeStop={onDragResizeStop}
isDraggable={isActive}
isResizable={isActive}
compactType={null}
preventCollision={false}
useCSSTransforms={true}
draggableHandle=".section-drag-handle"
>
{sections.map((section) => (
<div
key={section.id}
className={cn(
"group relative flex flex-col rounded-lg border-2 bg-white transition-all overflow-hidden",
selectedSectionId === section.id
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-gray-400"
)}
onClick={(e) => {
e.stopPropagation();
onSelectSection(section.id);
}}
>
{/* 섹션 헤더 - 고정 높이 */}
<div
className={cn(
"section-drag-handle flex h-7 shrink-0 cursor-move items-center justify-between border-b px-2",
selectedSectionId === section.id
? "bg-primary/10"
: "bg-gray-50"
)}
>
<div className="flex items-center gap-1">
<GripVertical className="h-3 w-3 text-gray-400" />
<span className="text-xs font-medium text-gray-600">
{section.label || `섹션`}
</span>
</div>
{selectedSectionId === section.id && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive hover:bg-destructive/10"
onClick={(e) => {
e.stopPropagation();
onDeleteSection(section.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
{/* 섹션 내부 - 나머지 영역 전부 차지 */}
<div className="relative flex-1">
<SectionGrid
section={section}
isActive={isActive}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
onDropComponent={onDropComponent}
onUpdateComponent={onUpdateComponent}
onDeleteComponent={onDeleteComponent}
/>
</div>
</div>
))}
</GridLayout>
) : (
<div
className={cn(
"flex h-full items-center justify-center rounded-lg border-2 border-dashed text-sm",
isOver && canDrop
? "border-primary bg-primary/5 text-primary"
: "border-gray-300 text-gray-400"
)}
>
{isOver && canDrop
? "여기에 섹션을 놓으세요"
: "왼쪽 패널에서 섹션을 드래그하세요"}
</div>
)}
</div>
);
}