ERP-node/frontend/components/report/designer/ReportDesignerCanvas.tsx

712 lines
24 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 { useRef, useEffect, useState, useCallback } 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 { WatermarkLayer } from "./WatermarkLayer";
import { v4 as uuidv4 } from "uuid";
import { MousePointer } from "lucide-react";
// mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정)
// A4 기준: 210mm x 297mm → 840px x 1188px
export const MM_TO_PX = 4;
export function ReportDesignerCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const {
currentPageId,
currentPage,
components,
addComponent,
updateComponent,
canvasWidth,
canvasHeight,
margins,
selectComponent,
selectMultipleComponents,
selectedComponentId,
selectedComponentIds,
removeComponent,
showGrid,
gridSize,
snapValueToGrid,
alignmentGuides,
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
fitSelectedToContent,
undo,
redo,
showRuler,
layoutConfig,
zoom,
setZoom,
fitTrigger,
} = useReportDesigner();
// 캔버스 Auto-Fit: fitTrigger 변경 시 컨테이너에 맞춰 줌 재계산
const calculateFitZoom = useCallback(() => {
if (!containerRef.current) return;
const rulerSpace = showRuler ? 20 : 0;
const padding = 24; // p-3 × 2
const availableWidth = containerRef.current.clientWidth - rulerSpace - padding;
const availableHeight = containerRef.current.clientHeight - rulerSpace - padding;
const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * MM_TO_PX;
if (availableWidth <= 0 || availableHeight <= 0 || canvasWidthPx <= 0 || canvasHeightPx <= 0) return;
const newZoom = Math.min(availableWidth / canvasWidthPx, availableHeight / canvasHeightPx, 1);
setZoom(Math.round(Math.max(0.1, newZoom) * 100) / 100);
}, [showRuler, canvasWidth, canvasHeight, setZoom]);
useEffect(() => {
calculateFitZoom();
}, [fitTrigger, calculateFitZoom]);
// 드래그 영역 선택 (Marquee Selection) 상태
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
const [marqueeStart, setMarqueeStart] = useState({ x: 0, y: 0 });
const [marqueeEnd, setMarqueeEnd] = useState({ x: 0, y: 0 });
// 클로저 문제 해결을 위한 refs (동기적으로 업데이트)
const marqueeStartRef = useRef({ x: 0, y: 0 });
const marqueeEndRef = useRef({ x: 0, y: 0 });
const componentsRef = useRef(components);
const selectMultipleRef = useRef(selectMultipleComponents);
// 마퀴 선택 직후 click 이벤트 무시를 위한 플래그
const justFinishedMarqueeRef = useRef(false);
// refs 동기적 업데이트 (useEffect 대신 직접 할당)
componentsRef.current = components;
selectMultipleRef.current = selectMultipleComponents;
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) / zoom;
const y = (offset.y - canvasRect.top) / zoom;
// 컴포넌트 타입별 기본 설정
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 = 10; // 선 두께 + 여백 (선택/드래그를 위한 최소 높이)
} else if (item.componentType === "signature") {
width = 120;
height = 70;
} else if (item.componentType === "stamp") {
width = 70;
height = 70;
} else if (item.componentType === "pageNumber") {
width = 100;
height = 30;
} else if (item.componentType === "barcode") {
width = 200;
height = 80;
} else if (item.componentType === "checkbox") {
width = 150;
height = 30;
}
// 여백을 px로 변환
const marginTopPx = margins.top * MM_TO_PX;
const marginLeftPx = margins.left * MM_TO_PX;
const marginRightPx = margins.right * MM_TO_PX;
const marginBottomPx = margins.bottom * MM_TO_PX;
// 캔버스 경계 (px)
const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * MM_TO_PX;
// 드롭 위치 계산 (여백 내부로 제한)
const rawX = x - 100;
const rawY = y - 25;
const minX = marginLeftPx;
const minY = marginTopPx;
const maxX = canvasWidthPx - marginRightPx - width;
const maxY = canvasHeightPx - marginBottomPx - height;
const boundedX = Math.min(Math.max(minX, rawX), maxX);
const boundedY = Math.min(Math.max(minY, rawY), maxY);
// 새 컴포넌트 생성 (Grid Snap 적용)
const newComponent: ComponentConfig = {
id: `comp_${uuidv4()}`,
type: item.componentType,
x: snapValueToGrid(boundedX),
y: snapValueToGrid(boundedY),
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,
imageOpacity: 1,
imageBorderRadius: 0,
imageCaption: "",
imageCaptionPosition: "bottom" as const,
imageCaptionFontSize: 12,
imageCaptionColor: "#666666",
imageCaptionAlign: "center" as const,
imageAlt: "",
imageRotation: 0,
imageFlipH: false,
imageFlipV: false,
}),
// 구분선 전용
...(item.componentType === "divider" && {
orientation: "horizontal" as const,
lineStyle: "solid" as const,
lineWidth: 1,
lineColor: "#000000",
}),
// 서명란 전용
...(item.componentType === "signature" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "서명:",
labelPosition: "left" as const,
borderWidth: 0,
borderColor: "#cccccc",
}),
// 도장란 전용
...(item.componentType === "stamp" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "(인)",
labelPosition: "top" as const,
personName: "",
borderWidth: 0,
borderColor: "#cccccc",
}),
// 페이지 번호 전용
...(item.componentType === "pageNumber" && {
pageNumberFormat: "number" as const, // number, numberTotal, koreanNumber
textAlign: "center" as const,
}),
// 카드 컴포넌트 전용
...(item.componentType === "card" && {
width: 300,
height: 180,
cardTitle: "정보 카드",
showCardTitle: true,
cardItems: [
{ label: "항목1", value: "내용1", fieldName: "" },
{ label: "항목2", value: "내용2", fieldName: "" },
{ label: "항목3", value: "내용3", fieldName: "" },
],
labelWidth: 80,
showCardBorder: true,
titleFontSize: 14,
labelFontSize: 13,
valueFontSize: 13,
titleColor: "#1e40af",
labelColor: "#374151",
valueColor: "#000000",
borderWidth: 1,
borderColor: "#e5e7eb",
}),
// 계산 컴포넌트 전용
...(item.componentType === "calculation" && {
width: 350,
height: 120,
calcItems: [
{ label: "공급가액", value: 0, operator: "+" as const, fieldName: "" },
{ label: "부가세 (10%)", value: 0, operator: "+" as const, fieldName: "" },
],
resultLabel: "합계 금액",
labelWidth: 120,
labelFontSize: 13,
valueFontSize: 13,
resultFontSize: 16,
labelColor: "#374151",
valueColor: "#000000",
resultColor: "#2563eb",
showCalcBorder: false,
numberFormat: "currency" as const,
currencySuffix: "원",
borderWidth: 0,
borderColor: "#e5e7eb",
}),
// 테이블 전용
...(item.componentType === "table" && {
queryId: undefined,
tableColumns: [],
headerBackgroundColor: "#f3f4f6",
headerTextColor: "#111827",
showBorder: true,
rowHeight: 32,
}),
// 바코드 컴포넌트 전용
...(item.componentType === "barcode" && {
barcodeType: "CODE128" as const,
barcodeValue: "SAMPLE123",
barcodeFieldName: "",
showBarcodeText: true,
barcodeColor: "#000000",
barcodeBackground: "transparent",
barcodeMargin: 10,
qrErrorCorrectionLevel: "M" as const,
}),
// 체크박스 컴포넌트 전용
...(item.componentType === "checkbox" && {
checkboxChecked: false,
checkboxLabel: "항목",
checkboxSize: 18,
checkboxColor: "#2563eb",
checkboxBorderColor: "#6b7280",
checkboxLabelPosition: "right" as const,
}),
};
addComponent(newComponent);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
}));
// 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만)
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
// 마퀴 선택 직후의 click 이벤트는 무시
if (justFinishedMarqueeRef.current) {
justFinishedMarqueeRef.current = false;
return;
}
if (e.target === e.currentTarget && !isMarqueeSelecting) {
selectComponent(null);
}
};
// 드래그 영역 선택 시작
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// 캔버스 자체를 클릭했을 때만 (컴포넌트 클릭 시 제외)
if (e.target !== e.currentTarget) return;
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / zoom;
const y = (e.clientY - rect.top) / zoom;
// state와 ref 모두 설정
setIsMarqueeSelecting(true);
setMarqueeStart({ x, y });
setMarqueeEnd({ x, y });
marqueeStartRef.current = { x, y };
marqueeEndRef.current = { x, y };
// Ctrl/Cmd 키가 눌리지 않았으면 기존 선택 해제
if (!e.ctrlKey && !e.metaKey) {
selectComponent(null);
}
};
// 드래그 영역 선택 중
useEffect(() => {
if (!isMarqueeSelecting) return;
const handleMouseMove = (e: MouseEvent) => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min((e.clientX - rect.left) / zoom, canvasWidth * MM_TO_PX));
const y = Math.max(0, Math.min((e.clientY - rect.top) / zoom, canvasHeight * MM_TO_PX));
// state와 ref 둘 다 업데이트
setMarqueeEnd({ x, y });
marqueeEndRef.current = { x, y };
};
const handleMouseUp = () => {
// ref에서 최신 값 가져오기 (클로저 문제 해결)
const currentStart = marqueeStartRef.current;
const currentEnd = marqueeEndRef.current;
const currentComponents = componentsRef.current;
const currentSelectMultiple = selectMultipleRef.current;
// 선택 영역 계산
const selectionRect = {
left: Math.min(currentStart.x, currentEnd.x),
top: Math.min(currentStart.y, currentEnd.y),
right: Math.max(currentStart.x, currentEnd.x),
bottom: Math.max(currentStart.y, currentEnd.y),
};
// 최소 드래그 거리 체크 (5px 이상이어야 선택으로 인식)
const dragDistance = Math.sqrt(
Math.pow(currentEnd.x - currentStart.x, 2) + Math.pow(currentEnd.y - currentStart.y, 2),
);
if (dragDistance > 5) {
// 선택 영역과 교차하는 컴포넌트 찾기
const intersectingComponents = currentComponents.filter((comp) => {
const compRect = {
left: comp.x,
top: comp.y,
right: comp.x + comp.width,
bottom: comp.y + comp.height,
};
// 두 사각형이 교차하는지 확인
return !(
compRect.right < selectionRect.left ||
compRect.left > selectionRect.right ||
compRect.bottom < selectionRect.top ||
compRect.top > selectionRect.bottom
);
});
// 교차하는 컴포넌트들 한번에 선택
if (intersectingComponents.length > 0) {
const ids = intersectingComponents.map((comp) => comp.id);
currentSelectMultiple(ids);
// click 이벤트가 선택을 해제하지 않도록 플래그 설정
justFinishedMarqueeRef.current = true;
}
}
setIsMarqueeSelecting(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isMarqueeSelecting, canvasWidth, canvasHeight]);
// 선택 영역 사각형 계산
const getMarqueeRect = () => {
return {
left: Math.min(marqueeStart.x, marqueeEnd.x),
top: Math.min(marqueeStart.y, marqueeEnd.y),
width: Math.abs(marqueeEnd.x - marqueeStart.x),
height: Math.abs(marqueeEnd.y - marqueeStart.y),
};
};
// 키보드 단축키 (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+Shift+C (또는 Cmd+Shift+C): 스타일 복사 (일반 복사보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "c") {
e.preventDefault();
copyStyles();
return;
}
// Ctrl+Shift+V (또는 Cmd+Shift+V): 스타일 붙여넣기 (일반 붙여넣기보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "v") {
e.preventDefault();
pasteStyles();
return;
}
// Ctrl+Shift+F (또는 Cmd+Shift+F): 텍스트 크기 자동 맞춤
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "f") {
e.preventDefault();
fitSelectedToContent();
return;
}
// Ctrl+C (또는 Cmd+C): 복사
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
e.preventDefault();
copyComponents();
return;
}
// Ctrl+V (또는 Cmd+V): 붙여넣기
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
e.preventDefault();
pasteComponents();
return;
}
// Ctrl+D (또는 Cmd+D): 복제
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") {
e.preventDefault();
duplicateComponents();
return;
}
// 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,
duplicateComponents,
copyStyles,
pasteStyles,
fitSelectedToContent,
undo,
redo,
]);
// 페이지가 없는 경우
if (!currentPageId || !currentPage) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-100">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-700"> </h3>
<p className="mt-2 text-sm text-gray-500"> .</p>
</div>
</div>
);
}
return (
<div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-gray-100">
{/* 캔버스 스크롤 영역 */}
<div ref={containerRef} className="flex flex-1 justify-center overflow-auto">
{/* 눈금자와 캔버스를 감싸는 컨테이너 (zoom 적용) */}
<div className="inline-flex flex-col p-3" style={{ zoom }}>
{/* 좌상단 코너 + 가로 눈금자 */}
{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-[0_4px_24px_rgba(0,0,0,0.12)] ${isOver ? "ring-2 ring-blue-500" : ""}`}
style={{
width: `${canvasWidth * MM_TO_PX}px`,
minHeight: `${canvasHeight * MM_TO_PX}px`,
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,
cursor: isMarqueeSelecting ? "crosshair" : "default",
}}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
>
{/* 페이지 여백 가이드 */}
{currentPage && (
<div
className="pointer-events-none absolute border-2 border-dashed border-blue-300/50"
style={{
top: `${currentPage.margins.top * MM_TO_PX}px`,
left: `${currentPage.margins.left * MM_TO_PX}px`,
right: `${currentPage.margins.right * MM_TO_PX}px`,
bottom: `${currentPage.margins.bottom * MM_TO_PX}px`,
}}
/>
)}
{/* 워터마크 렌더링 (전체 페이지 공유) */}
{layoutConfig.watermark?.enabled && (
<WatermarkLayer
watermark={layoutConfig.watermark}
width={canvasWidth * MM_TO_PX}
height={canvasHeight * MM_TO_PX}
/>
)}
{/* 정렬 가이드라인 렌더링 */}
{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} />
))}
{/* 드래그 영역 선택 사각형 */}
{isMarqueeSelecting && (
<div
className="pointer-events-none absolute border-2 border-blue-500 bg-blue-500/10"
style={{
left: `${getMarqueeRect().left}px`,
top: `${getMarqueeRect().top}px`,
width: `${getMarqueeRect().width}px`,
height: `${getMarqueeRect().height}px`,
zIndex: 10000,
}}
/>
)}
{/* 빈 캔버스 안내 */}
{components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center">
<MousePointer className="mx-auto mb-4 h-12 w-12 text-gray-300" />
<p className="mb-1 text-base text-gray-400"> </p>
<p className="text-base text-gray-400"> </p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}