"use client"; import { useRef, useEffect, useState } 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 (
{watermark.type === "text" ? ( {watermark.text || "WATERMARK"} ) : ( watermark.imageUrl && ( watermark ) )}
); } // 중앙 스타일 if (watermark.style === "center") { return (
{watermark.type === "text" ? ( {watermark.text || "WATERMARK"} ) : ( watermark.imageUrl && ( watermark ) )}
); } // 타일 스타일 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 (
{Array.from({ length: rows * cols }).map((_, index) => (
{watermark.type === "text" ? ( {watermark.text || "WATERMARK"} ) : ( watermark.imageUrl && ( watermark ) )}
))}
); } return null; } export function ReportDesignerCanvas() { const canvasRef = useRef(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, } = useReportDesigner(); // 드래그 영역 선택 (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; 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, 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) => { // 마퀴 선택 직후의 click 이벤트는 무시 if (justFinishedMarqueeRef.current) { justFinishedMarqueeRef.current = false; return; } if (e.target === e.currentTarget && !isMarqueeSelecting) { selectComponent(null); } }; // 드래그 영역 선택 시작 const handleCanvasMouseDown = (e: React.MouseEvent) => { // 캔버스 자체를 클릭했을 때만 (컴포넌트 클릭 시 제외) if (e.target !== e.currentTarget) return; if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // 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, canvasWidth * MM_TO_PX)); const y = Math.max(0, Math.min(e.clientY - rect.top, 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 (

페이지가 없습니다

좌측에서 페이지를 추가하세요.

); } return (
{/* 캔버스 스크롤 영역 */}
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
{/* 좌상단 코너 + 가로 눈금자 */} {showRuler && (
{/* 좌상단 코너 (20x20) */}
{/* 가로 눈금자 */}
)} {/* 세로 눈금자 + 캔버스 */}
{/* 세로 눈금자 */} {showRuler && } {/* 캔버스 */}
{ 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, cursor: isMarqueeSelecting ? "crosshair" : "default", }} onClick={handleCanvasClick} onMouseDown={handleCanvasMouseDown} > {/* 페이지 여백 가이드 */} {currentPage && (
)} {/* 워터마크 렌더링 (전체 페이지 공유) */} {layoutConfig.watermark?.enabled && ( )} {/* 정렬 가이드라인 렌더링 */} {alignmentGuides.vertical.map((x, index) => (
))} {alignmentGuides.horizontal.map((y, index) => (
))} {/* 컴포넌트 렌더링 */} {components.map((component) => ( ))} {/* 드래그 영역 선택 사각형 */} {isMarqueeSelecting && (
)} {/* 빈 캔버스 안내 */} {components.length === 0 && (

왼쪽에서 컴포넌트를 드래그하여 추가하세요

)}
); }