Files
vexplor/frontend/components/report/designer/ReportDesignerCanvas.tsx
2025-10-01 16:53:35 +09:00

313 lines
9.7 KiB
TypeScript

"use client";
import { useRef, useEffect } from "react";
import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig } from "@/types/report";
import { CanvasComponent } from "./CanvasComponent";
import { Ruler } from "./Ruler";
import { v4 as uuidv4 } from "uuid";
export function ReportDesignerCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
const {
components,
addComponent,
updateComponent,
canvasWidth,
canvasHeight,
selectComponent,
selectedComponentId,
selectedComponentIds,
removeComponent,
showGrid,
gridSize,
snapValueToGrid,
alignmentGuides,
copyComponents,
pasteComponents,
undo,
redo,
showRuler,
} = useReportDesigner();
const [{ isOver }, drop] = useDrop(() => ({
accept: "component",
drop: (item: { componentType: string }, monitor) => {
if (!canvasRef.current) return;
const offset = monitor.getClientOffset();
const canvasRect = canvasRef.current.getBoundingClientRect();
if (!offset) return;
const x = offset.x - canvasRect.left;
const y = offset.y - canvasRect.top;
// 컴포넌트 타입별 기본 설정
let width = 200;
let height = 100;
if (item.componentType === "table") {
height = 200;
} else if (item.componentType === "image") {
width = 150;
height = 150;
} else if (item.componentType === "divider") {
width = 300;
height = 2;
}
// 새 컴포넌트 생성 (Grid Snap 적용)
const newComponent: ComponentConfig = {
id: `comp_${uuidv4()}`,
type: item.componentType,
x: snapValueToGrid(Math.max(0, x - 100)),
y: snapValueToGrid(Math.max(0, y - 25)),
width: snapValueToGrid(width),
height: snapValueToGrid(height),
zIndex: components.length,
fontSize: 13,
fontFamily: "Malgun Gothic",
fontWeight: "normal",
fontColor: "#000000",
backgroundColor: "transparent",
borderWidth: 0,
borderColor: "#cccccc",
borderRadius: 5,
textAlign: "left",
padding: 10,
visible: true,
printable: true,
// 이미지 전용
...(item.componentType === "image" && {
imageUrl: "",
objectFit: "contain" as const,
}),
// 구분선 전용
...(item.componentType === "divider" && {
orientation: "horizontal" as const,
lineStyle: "solid" as const,
lineWidth: 1,
lineColor: "#000000",
}),
};
addComponent(newComponent);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
}));
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
selectComponent(null);
}
};
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 입력 필드에서는 단축키 무시
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
return;
}
// 화살표 키: 선택된 컴포넌트 이동
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
e.preventDefault();
// 선택된 컴포넌트가 없으면 무시
if (!selectedComponentId && selectedComponentIds.length === 0) {
return;
}
// 이동 거리 (Shift 키를 누르면 10px, 아니면 1px)
const moveDistance = e.shiftKey ? 10 : 1;
// 이동할 컴포넌트 ID 목록
const idsToMove =
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
// 각 컴포넌트 이동 (잠긴 컴포넌트는 제외)
idsToMove.forEach((id) => {
const component = components.find((c) => c.id === id);
if (!component || component.locked) return;
let newX = component.x;
let newY = component.y;
switch (e.key) {
case "ArrowLeft":
newX = Math.max(0, component.x - moveDistance);
break;
case "ArrowRight":
newX = component.x + moveDistance;
break;
case "ArrowUp":
newY = Math.max(0, component.y - moveDistance);
break;
case "ArrowDown":
newY = component.y + moveDistance;
break;
}
updateComponent(id, { x: newX, y: newY });
});
return;
}
// Delete 키: 삭제 (잠긴 컴포넌트는 제외)
if (e.key === "Delete") {
if (selectedComponentIds.length > 0) {
selectedComponentIds.forEach((id) => {
const component = components.find((c) => c.id === id);
if (component && !component.locked) {
removeComponent(id);
}
});
} else if (selectedComponentId) {
const component = components.find((c) => c.id === selectedComponentId);
if (component && !component.locked) {
removeComponent(selectedComponentId);
}
}
}
// Ctrl+C (또는 Cmd+C): 복사
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
e.preventDefault();
copyComponents();
}
// Ctrl+V (또는 Cmd+V): 붙여넣기
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
pasteComponents();
}
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "z") {
e.preventDefault();
redo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
redo();
return;
}
// Ctrl+Z (또는 Cmd+Z): Undo
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
undo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedComponentId,
selectedComponentIds,
components,
removeComponent,
copyComponents,
pasteComponents,
undo,
redo,
]);
return (
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
{/* 작업 영역 제목 */}
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700"> </div>
{/* 캔버스 스크롤 영역 */}
<div className="flex-1 overflow-auto p-8">
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
<div className="mx-auto inline-flex flex-col">
{/* 좌상단 코너 + 가로 눈금자 */}
{showRuler && (
<div className="flex">
{/* 좌상단 코너 (20x20) */}
<div className="h-5 w-5 bg-gray-200" />
{/* 가로 눈금자 */}
<Ruler orientation="horizontal" length={canvasWidth} />
</div>
)}
{/* 세로 눈금자 + 캔버스 */}
<div className="flex">
{/* 세로 눈금자 */}
{showRuler && <Ruler orientation="vertical" length={canvasHeight} />}
{/* 캔버스 */}
<div
ref={(node) => {
canvasRef.current = node;
drop(node);
}}
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
style={{
width: `${canvasWidth}mm`,
minHeight: `${canvasHeight}mm`,
backgroundImage: showGrid
? `
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
`
: undefined,
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
}}
onClick={handleCanvasClick}
>
{/* 정렬 가이드라인 렌더링 */}
{alignmentGuides.vertical.map((x, index) => (
<div
key={`v-${index}`}
className="pointer-events-none absolute top-0 bottom-0"
style={{
left: `${x}px`,
width: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{alignmentGuides.horizontal.map((y, index) => (
<div
key={`h-${index}`}
className="pointer-events-none absolute right-0 left-0"
style={{
top: `${y}px`,
height: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{/* 컴포넌트 렌더링 */}
{components.map((component) => (
<CanvasComponent key={component.id} component={component} />
))}
{/* 빈 캔버스 안내 */}
{components.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<p className="text-sm"> </p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}