379 lines
12 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|