665 lines
21 KiB
TypeScript
665 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useEffect } from "react";
|
|
import { useDrop } from "react-dnd";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import { ComponentConfig, WatermarkConfig } from "@/types/report";
|
|
import { CanvasComponent } from "./CanvasComponent";
|
|
import { Ruler } from "./Ruler";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { getFullImageUrl } from "@/lib/api/client";
|
|
|
|
// mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정)
|
|
// A4 기준: 210mm x 297mm → 840px x 1188px
|
|
export const MM_TO_PX = 4;
|
|
|
|
// 워터마크 레이어 컴포넌트
|
|
interface WatermarkLayerProps {
|
|
watermark: WatermarkConfig;
|
|
canvasWidth: number;
|
|
canvasHeight: number;
|
|
}
|
|
|
|
function WatermarkLayer({ watermark, canvasWidth, canvasHeight }: WatermarkLayerProps) {
|
|
// 공통 스타일
|
|
const baseStyle: React.CSSProperties = {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
height: "100%",
|
|
pointerEvents: "none",
|
|
overflow: "hidden",
|
|
zIndex: 1, // 컴포넌트보다 낮은 z-index
|
|
};
|
|
|
|
// 대각선 스타일
|
|
if (watermark.style === "diagonal") {
|
|
const rotation = watermark.rotation ?? -45;
|
|
return (
|
|
<div style={baseStyle}>
|
|
<div
|
|
className="absolute flex items-center justify-center"
|
|
style={{
|
|
top: "50%",
|
|
left: "50%",
|
|
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
|
opacity: watermark.opacity,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{watermark.type === "text" ? (
|
|
<span
|
|
style={{
|
|
fontSize: `${watermark.fontSize || 48}px`,
|
|
color: watermark.fontColor || "#cccccc",
|
|
fontWeight: "bold",
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
{watermark.text || "WATERMARK"}
|
|
</span>
|
|
) : (
|
|
watermark.imageUrl && (
|
|
<img
|
|
src={getFullImageUrl(watermark.imageUrl)}
|
|
alt="watermark"
|
|
style={{
|
|
maxWidth: "50%",
|
|
maxHeight: "50%",
|
|
objectFit: "contain",
|
|
}}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 중앙 스타일
|
|
if (watermark.style === "center") {
|
|
return (
|
|
<div style={baseStyle}>
|
|
<div
|
|
className="absolute flex items-center justify-center"
|
|
style={{
|
|
top: "50%",
|
|
left: "50%",
|
|
transform: "translate(-50%, -50%)",
|
|
opacity: watermark.opacity,
|
|
}}
|
|
>
|
|
{watermark.type === "text" ? (
|
|
<span
|
|
style={{
|
|
fontSize: `${watermark.fontSize || 48}px`,
|
|
color: watermark.fontColor || "#cccccc",
|
|
fontWeight: "bold",
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
{watermark.text || "WATERMARK"}
|
|
</span>
|
|
) : (
|
|
watermark.imageUrl && (
|
|
<img
|
|
src={getFullImageUrl(watermark.imageUrl)}
|
|
alt="watermark"
|
|
style={{
|
|
maxWidth: "50%",
|
|
maxHeight: "50%",
|
|
objectFit: "contain",
|
|
}}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 타일 스타일
|
|
if (watermark.style === "tile") {
|
|
const rotation = watermark.rotation ?? -30;
|
|
// 타일 간격 계산
|
|
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
|
const cols = Math.ceil(canvasWidth / tileSize) + 2;
|
|
const rows = Math.ceil(canvasHeight / tileSize) + 2;
|
|
|
|
return (
|
|
<div style={baseStyle}>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: "-50%",
|
|
left: "-50%",
|
|
width: "200%",
|
|
height: "200%",
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignContent: "flex-start",
|
|
transform: `rotate(${rotation}deg)`,
|
|
opacity: watermark.opacity,
|
|
}}
|
|
>
|
|
{Array.from({ length: rows * cols }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
style={{
|
|
width: `${tileSize}px`,
|
|
height: `${tileSize}px`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
{watermark.type === "text" ? (
|
|
<span
|
|
style={{
|
|
fontSize: `${watermark.fontSize || 24}px`,
|
|
color: watermark.fontColor || "#cccccc",
|
|
fontWeight: "bold",
|
|
userSelect: "none",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{watermark.text || "WATERMARK"}
|
|
</span>
|
|
) : (
|
|
watermark.imageUrl && (
|
|
<img
|
|
src={getFullImageUrl(watermark.imageUrl)}
|
|
alt="watermark"
|
|
style={{
|
|
width: `${tileSize * 0.6}px`,
|
|
height: `${tileSize * 0.6}px`,
|
|
objectFit: "contain",
|
|
}}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function ReportDesignerCanvas() {
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
const {
|
|
currentPageId,
|
|
currentPage,
|
|
components,
|
|
addComponent,
|
|
updateComponent,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
margins,
|
|
selectComponent,
|
|
selectedComponentId,
|
|
selectedComponentIds,
|
|
removeComponent,
|
|
showGrid,
|
|
gridSize,
|
|
snapValueToGrid,
|
|
alignmentGuides,
|
|
copyComponents,
|
|
pasteComponents,
|
|
undo,
|
|
redo,
|
|
showRuler,
|
|
layoutConfig,
|
|
} = 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 = 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,
|
|
}),
|
|
// 구분선 전용
|
|
...(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,
|
|
showUnderline: true,
|
|
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>) => {
|
|
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,
|
|
]);
|
|
|
|
// 페이지가 없는 경우
|
|
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 flex-1 flex-col overflow-hidden bg-gray-100">
|
|
{/* 캔버스 스크롤 영역 */}
|
|
<div className="flex flex-1 items-center justify-center overflow-auto px-8 pt-[280px] pb-8">
|
|
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
|
|
<div className="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_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,
|
|
}}
|
|
onClick={handleCanvasClick}
|
|
>
|
|
{/* 페이지 여백 가이드 */}
|
|
{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}
|
|
canvasWidth={canvasWidth * MM_TO_PX}
|
|
canvasHeight={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} />
|
|
))}
|
|
|
|
{/* 빈 캔버스 안내 */}
|
|
{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>
|
|
);
|
|
}
|