"use client"; import { useRef, useState, useEffect } from "react"; import { ComponentConfig } from "@/types/report"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; const MM_TO_PX = 4; const HANDLE_SIZE = 8; const HANDLE_PX = `${HANDLE_SIZE}px`; type HandleDef = { dir: string; cursor: string; size: string; top?: string; left?: string; right?: string; bottom?: string; transform: string; }; const RESIZE_HANDLES_ALL: HandleDef[] = [ { dir: "nw", cursor: "cursor-nw-resize", size: HANDLE_PX, top: "0", left: "0", transform: "translate(-50%,-50%)" }, { dir: "n", cursor: "cursor-n-resize", size: HANDLE_PX, top: "0", left: "50%", transform: "translate(-50%,-50%)" }, { dir: "ne", cursor: "cursor-ne-resize", size: HANDLE_PX, top: "0", right: "0", transform: "translate(50%,-50%)" }, { dir: "w", cursor: "cursor-w-resize", size: HANDLE_PX, top: "50%", left: "0", transform: "translate(-50%,-50%)" }, { dir: "e", cursor: "cursor-e-resize", size: HANDLE_PX, top: "50%", right: "0", transform: "translate(50%,-50%)" }, { dir: "sw", cursor: "cursor-sw-resize", size: HANDLE_PX, bottom: "0", left: "0", transform: "translate(-50%,50%)" }, { dir: "s", cursor: "cursor-s-resize", size: HANDLE_PX, bottom: "0", left: "50%", transform: "translate(-50%,50%)" }, { dir: "se", cursor: "cursor-se-resize", size: HANDLE_PX, bottom: "0", right: "0", transform: "translate(50%,50%)" }, ]; const RESIZE_HANDLES_HORIZONTAL_DIVIDER: HandleDef[] = [ { dir: "w", cursor: "cursor-w-resize", size: HANDLE_PX, top: "50%", left: "0", transform: "translate(-50%,-50%)" }, { dir: "e", cursor: "cursor-e-resize", size: HANDLE_PX, top: "50%", right: "0", transform: "translate(50%,-50%)" }, ]; const RESIZE_HANDLES_VERTICAL_DIVIDER: HandleDef[] = [ { dir: "n", cursor: "cursor-n-resize", size: HANDLE_PX, top: "0", left: "50%", transform: "translate(-50%,-50%)" }, { dir: "s", cursor: "cursor-s-resize", size: HANDLE_PX, bottom: "0", left: "50%", transform: "translate(-50%,50%)" }, ]; import { TextRenderer, TableRenderer, ImageRenderer, DividerRenderer, SignatureRenderer, StampRenderer, PageNumberRenderer, CardRenderer, CalculationRenderer, BarcodeCanvasRenderer, CheckboxRenderer, } from "./renderers"; interface CanvasComponentProps { component: ComponentConfig; } export function CanvasComponent({ component }: CanvasComponentProps) { const { components, selectedComponentId, selectedComponentIds, selectComponent, selectMultipleComponents, updateComponent, getQueryResult, snapValueToGrid, calculateAlignmentGuides, clearAlignmentGuides, canvasWidth, canvasHeight, margins, layoutConfig, currentPageId, duplicateAtPosition, openComponentModal, removeComponent, } = useReportDesigner(); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [resizeDirection, setResizeDirection] = useState(""); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0, compX: 0, compY: 0 }); const componentRef = useRef(null); // Alt+드래그 복제를 위한 상태 const [isAltDuplicating, setIsAltDuplicating] = useState(false); const duplicatedIdsRef = useRef([]); // 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용) const originalPositionsRef = useRef>(new Map()); const isSelected = selectedComponentId === component.id; const isMultiSelected = selectedComponentIds.includes(component.id); const isLocked = component.locked === true; const isGrouped = !!component.groupId; // 표시할 값 결정 const getDisplayValue = (): string => { // 쿼리와 필드가 연결되어 있으면 실제 데이터 조회 if (component.queryId && component.fieldName) { const queryResult = getQueryResult(component.queryId); // 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시 if (queryResult && queryResult.rows.length > 0) { const firstRow = queryResult.rows[0]; const value = firstRow[component.fieldName]; // 값이 있으면 문자열로 변환하여 반환 if (value !== null && value !== undefined) { return String(value); } } // 실행 결과가 없거나 값이 없으면 필드명 표시 return `{${component.fieldName}}`; } // 기본값이 있으면 기본값 표시 if (component.defaultValue) { return component.defaultValue; } // 둘 다 없으면 타입에 따라 기본 텍스트 return component.type === "text" ? "텍스트 입력" : "레이블 텍스트"; }; // 텍스트 컴포넌트: 더블 클릭 시 컨텐츠에 맞게 크기 조절 const fitTextToContent = () => { if (isLocked) return; if (component.type !== "text" && component.type !== "label") return; const minWidth = 50; const minHeight = 30; // 여백을 px로 변환 const marginRightPx = margins.right * MM_TO_PX; const marginBottomPx = margins.bottom * MM_TO_PX; const canvasWidthPx = canvasWidth * MM_TO_PX; const canvasHeightPx = canvasHeight * MM_TO_PX; // 최대 크기 (여백 고려) const maxWidth = canvasWidthPx - marginRightPx - component.x; const maxHeight = canvasHeightPx - marginBottomPx - component.y; const displayValue = getDisplayValue(); const fontSize = component.fontSize || 14; // 줄바꿈으로 분리하여 각 줄의 너비 측정 const lines = displayValue.split("\n"); let maxLineWidth = 0; lines.forEach((line) => { const measureEl = document.createElement("span"); measureEl.style.position = "absolute"; measureEl.style.visibility = "hidden"; measureEl.style.whiteSpace = "nowrap"; measureEl.style.fontSize = `${fontSize}px`; measureEl.style.fontWeight = component.fontWeight || "normal"; measureEl.style.fontFamily = component.fontFamily || "Malgun Gothic"; measureEl.textContent = line || " "; // 빈 줄은 공백으로 document.body.appendChild(measureEl); const lineWidth = measureEl.getBoundingClientRect().width; maxLineWidth = Math.max(maxLineWidth, lineWidth); document.body.removeChild(measureEl); }); // 컴포넌트 padding (p-2 = 8px * 2) + 여유분 const horizontalPadding = 24; const verticalPadding = 20; // 줄 높이 계산 (font-size * line-height 약 1.5) const lineHeight = fontSize * 1.5; const totalHeight = lines.length * lineHeight; const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth); const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight); const newWidth = Math.max(minWidth, finalWidth); const newHeight = Math.max(minHeight, finalHeight); // 크기 업데이트 updateComponent(component.id, { width: snapValueToGrid(newWidth), height: snapValueToGrid(newHeight), }); }; // 더블 클릭 → 인캔버스 설정 모달 오픈 (Phase 5) const handleDoubleClick = (e: React.MouseEvent) => { if (isLocked) return; if (component.type === "divider") return; e.stopPropagation(); openComponentModal(component.id); }; // 드래그 시작 const handleMouseDown = (e: React.MouseEvent) => { if ((e.target as HTMLElement).classList.contains("resize-handle")) { return; } // 잠긴 컴포넌트는 드래그 불가 if (isLocked) { e.stopPropagation(); // Ctrl/Cmd 키 감지 (다중 선택) const isMultiSelect = e.ctrlKey || e.metaKey; selectComponent(component.id, isMultiSelect); return; } e.stopPropagation(); // Ctrl/Cmd 키 감지 (다중 선택) const isMultiSelect = e.ctrlKey || e.metaKey; // Alt 키 감지 (복제 드래그) const isAltPressed = e.altKey; // 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작) const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id); if (!isPartOfMultiSelection) { // 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택 if (isGrouped && !isMultiSelect) { const groupMembers = components.filter((c) => c.groupId === component.groupId); const groupMemberIds = groupMembers.map((c) => c.id); // 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가 selectComponent(groupMemberIds[0], false); groupMemberIds.slice(1).forEach((id) => selectComponent(id, true)); } else { selectComponent(component.id, isMultiSelect); } } // Alt+드래그: 복제 모드 if (isAltPressed) { // 복제할 컴포넌트 ID 목록 결정 let idsToClone: string[] = []; if (isPartOfMultiSelection) { // 다중 선택된 경우: 잠기지 않은 선택된 모든 컴포넌트 복제 idsToClone = selectedComponentIds.filter((id) => { const c = components.find((comp) => comp.id === id); return c && !c.locked; }); } else if (isGrouped) { // 그룹화된 경우: 같은 그룹의 모든 컴포넌트 복제 idsToClone = components.filter((c) => c.groupId === component.groupId && !c.locked).map((c) => c.id); } else { // 단일 컴포넌트 idsToClone = [component.id]; } if (idsToClone.length > 0) { // 원본 컴포넌트들의 위치 저장 (복제본 ID -> 원본 위치 매핑용) const positionsMap = new Map(); idsToClone.forEach((id) => { const comp = components.find((c) => c.id === id); if (comp) { positionsMap.set(id, { x: comp.x, y: comp.y }); } }); // 복제 생성 (오프셋 없이 원래 위치에) const newIds = duplicateAtPosition(idsToClone, 0, 0); if (newIds.length > 0) { // 복제된 컴포넌트 ID와 원본 위치 매핑 // newIds[i]는 idsToClone[i]에서 복제됨 const dupPositionsMap = new Map(); newIds.forEach((newId, index) => { const originalId = idsToClone[index]; const originalPos = positionsMap.get(originalId); if (originalPos) { dupPositionsMap.set(newId, originalPos); } }); originalPositionsRef.current = dupPositionsMap; // 복제된 컴포넌트들을 선택하고 드래그 시작 duplicatedIdsRef.current = newIds; setIsAltDuplicating(true); // 복제된 컴포넌트들 선택 if (newIds.length === 1) { selectComponent(newIds[0], false); } else { selectMultipleComponents(newIds); } } } } setIsDragging(true); const hitAreaOffsetY = isHorizontalDivider ? (DIVIDER_HIT_AREA - component.height) / 2 : 0; const hitAreaOffsetX = isVerticalDivider ? (DIVIDER_HIT_AREA - component.width) / 2 : 0; setDragStart({ x: e.clientX - component.x + hitAreaOffsetX, y: e.clientY - component.y + hitAreaOffsetY, }); }; // 리사이즈 시작 (방향 인자) const handleResizeStart = (e: React.MouseEvent, direction: string) => { if (isLocked) { e.stopPropagation(); return; } e.stopPropagation(); setIsResizing(true); setResizeDirection(direction); setResizeStart({ x: e.clientX, y: e.clientY, width: component.width, height: component.height, compX: component.x, compY: component.y, }); }; // 마우스 이동 핸들러 (전역) useEffect(() => { if (!isDragging && !isResizing) return; const handleMouseMove = (e: MouseEvent) => { if (isDragging) { const newX = Math.max(0, e.clientX - dragStart.x); const newY = Math.max(0, e.clientY - dragStart.y); // 여백을 px로 변환 const marginTopPx = margins.top * MM_TO_PX; const marginBottomPx = margins.bottom * MM_TO_PX; const marginLeftPx = margins.left * MM_TO_PX; const marginRightPx = margins.right * MM_TO_PX; // 캔버스 경계 체크 (mm를 px로 변환) const canvasWidthPx = canvasWidth * MM_TO_PX; const canvasHeightPx = canvasHeight * MM_TO_PX; // 컴포넌트가 여백 안에 있도록 제한 const minX = marginLeftPx; const minY = marginTopPx; const maxX = canvasWidthPx - marginRightPx - component.width; const maxY = canvasHeightPx - marginBottomPx - component.height; const boundedX = Math.min(Math.max(minX, newX), maxX); const boundedY = Math.min(Math.max(minY, newY), maxY); const snappedX = snapValueToGrid(boundedX); const snappedY = snapValueToGrid(boundedY); // 정렬 가이드라인 계산 calculateAlignmentGuides(component.id, snappedX, snappedY, component.width, component.height); // 이동 거리 계산 const deltaX = snappedX - component.x; const deltaY = snappedY - component.y; // Alt+드래그 복제 모드: 원본은 이동하지 않고 복제본만 이동 if (isAltDuplicating && duplicatedIdsRef.current.length > 0) { // 복제된 컴포넌트들 이동 (각각의 원본 위치 기준으로 절대 위치 설정) duplicatedIdsRef.current.forEach((dupId) => { const dupComp = components.find((c) => c.id === dupId); const originalPos = originalPositionsRef.current.get(dupId); if (dupComp && originalPos) { // 각 복제본의 원본 위치에서 delta만큼 이동 const targetX = originalPos.x + deltaX; const targetY = originalPos.y + deltaY; // 경계 체크 const dupMaxX = canvasWidthPx - marginRightPx - dupComp.width; const dupMaxY = canvasHeightPx - marginBottomPx - dupComp.height; updateComponent(dupId, { x: Math.min(Math.max(marginLeftPx, targetX), dupMaxX), y: Math.min(Math.max(marginTopPx, targetY), dupMaxY), }); } }); return; // 원본 컴포넌트는 이동하지 않음 } // 현재 컴포넌트 이동 updateComponent(component.id, { x: snappedX, y: snappedY, }); // 다중 선택된 경우: 선택된 다른 컴포넌트들도 함께 이동 if (selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id)) { components.forEach((c) => { // 현재 컴포넌트는 이미 이동됨, 잠긴 컴포넌트는 제외 if (c.id !== component.id && selectedComponentIds.includes(c.id) && !c.locked) { const newMultiX = c.x + deltaX; const newMultiY = c.y + deltaY; // 경계 체크 const multiMaxX = canvasWidthPx - marginRightPx - c.width; const multiMaxY = canvasHeightPx - marginBottomPx - c.height; updateComponent(c.id, { x: Math.min(Math.max(marginLeftPx, newMultiX), multiMaxX), y: Math.min(Math.max(marginTopPx, newMultiY), multiMaxY), }); } }); } // 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동 else if (isGrouped) { components.forEach((c) => { if (c.groupId === component.groupId && c.id !== component.id) { const newGroupX = c.x + deltaX; const newGroupY = c.y + deltaY; // 그룹 컴포넌트도 경계 체크 const groupMaxX = canvasWidthPx - c.width; const groupMaxY = canvasHeightPx - c.height; updateComponent(c.id, { x: Math.min(Math.max(0, newGroupX), groupMaxX), y: Math.min(Math.max(0, newGroupY), groupMaxY), }); } }); } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; const deltaY = e.clientY - resizeStart.y; 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; const canvasWidthPx = canvasWidth * MM_TO_PX; const canvasHeightPx = canvasHeight * MM_TO_PX; const dir = resizeDirection; const resizesLeft = dir.includes("w"); const resizesRight = dir.includes("e"); const resizesTop = dir.includes("n"); const resizesBottom = dir.includes("s"); let newX = resizeStart.compX; let newY = resizeStart.compY; let newW = resizeStart.width; let newH = resizeStart.height; if (resizesRight) { newW = resizeStart.width + deltaX; } if (resizesLeft) { newW = resizeStart.width - deltaX; newX = resizeStart.compX + deltaX; } if (resizesBottom) { newH = resizeStart.height + deltaY; } if (resizesTop) { newH = resizeStart.height - deltaY; newY = resizeStart.compY + deltaY; } const MIN_W = 20; const MIN_H = 10; // 최소 크기 제한 + 위치 보정 if (newW < MIN_W) { if (resizesLeft) newX = resizeStart.compX + resizeStart.width - MIN_W; newW = MIN_W; } if (newH < MIN_H) { if (resizesTop) newY = resizeStart.compY + resizeStart.height - MIN_H; newH = MIN_H; } // 캔버스 경계 제한 if (newX < marginLeftPx) { newW -= marginLeftPx - newX; newX = marginLeftPx; } if (newY < marginTopPx) { newH -= marginTopPx - newY; newY = marginTopPx; } const maxRight = canvasWidthPx - marginRightPx; const maxBottom = canvasHeightPx - marginBottomPx; if (newX + newW > maxRight) newW = maxRight - newX; if (newY + newH > maxBottom) newH = maxBottom - newY; // 구분선: 방향에 따라 한 축만 조절 if (component.type === "divider") { if (component.orientation === "vertical") { updateComponent(component.id, { y: snapValueToGrid(newY), height: snapValueToGrid(newH), }); } else { updateComponent(component.id, { x: snapValueToGrid(newX), width: snapValueToGrid(newW), }); } } else if (component.type === "barcode" && component.barcodeType === "QR") { const maxDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY; const newSize = Math.max(50, resizeStart.width + maxDelta); const maxSize = Math.min(maxRight - component.x, maxBottom - component.y); const snappedSize = snapValueToGrid(Math.min(newSize, maxSize)); updateComponent(component.id, { width: snappedSize, height: snappedSize }); } else { updateComponent(component.id, { x: snapValueToGrid(newX), y: snapValueToGrid(newY), width: snapValueToGrid(newW), height: snapValueToGrid(newH), }); } } }; const handleMouseUp = () => { setIsDragging(false); setIsResizing(false); // Alt 복제 상태 초기화 setIsAltDuplicating(false); duplicatedIdsRef.current = []; originalPositionsRef.current = new Map(); // 가이드라인 초기화 clearAlignmentGuides(); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [ isDragging, isResizing, isAltDuplicating, resizeDirection, dragStart.x, dragStart.y, resizeStart.x, resizeStart.y, resizeStart.width, resizeStart.height, resizeStart.compX, resizeStart.compY, component.id, component.x, component.y, component.width, component.height, component.groupId, isGrouped, components, updateComponent, snapValueToGrid, calculateAlignmentGuides, clearAlignmentGuides, canvasWidth, canvasHeight, ]); // 단일 조건 평가 const evaluateSingleRule = (rule: { queryId: string; field: string; operator: string; value: string }): boolean => { const queryResult = getQueryResult(rule.queryId); if (!queryResult?.rows?.length) return false; const rawValue = queryResult.rows[0][rule.field]; const fieldStr = rawValue !== null && rawValue !== undefined ? String(rawValue) : ""; const fieldNum = parseFloat(fieldStr); const compareNum = parseFloat(rule.value); switch (rule.operator) { case "eq": return fieldStr === rule.value; case "ne": return fieldStr !== rule.value; case "gt": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum > compareNum; case "lt": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum < compareNum; case "gte": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum >= compareNum; case "lte": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum <= compareNum; case "contains": return fieldStr.includes(rule.value); case "notEmpty": return fieldStr !== ""; case "empty": return fieldStr === ""; default: return false; } }; // 조건부 표시 규칙 평가 (다중 AND 조건 지원) // 격자 모드일 때는 gridConditionalRules를, 일반 테이블일 때는 conditionalRules를 사용 const evaluateConditional = (): boolean => { const isGrid = component.gridMode === true; const rulesArr = isGrid ? component.gridConditionalRules : component.conditionalRules; const singleRule = isGrid ? component.gridConditionalRule : component.conditionalRule; const rules = rulesArr?.length ? rulesArr : singleRule ? [singleRule] : []; if (rules.length === 0) return true; const validRules = rules.filter((r) => r.queryId && r.field); if (validRules.length === 0) return true; const action = validRules[0].action; const allMet = validRules.every((rule) => evaluateSingleRule(rule)); return action === "show" ? allMet : !allMet; }; const isConditionalVisible = evaluateConditional(); const isDivider = component.type === "divider"; const isHorizontalDivider = isDivider && component.orientation !== "vertical"; const isVerticalDivider = isDivider && component.orientation === "vertical"; const DIVIDER_HIT_AREA = 24; // 컴포넌트 타입별 렌더링 const renderContent = () => { const displayValue = getDisplayValue(); switch (component.type) { case "text": case "label": return ; case "table": return ; case "image": return ; case "divider": return (
); case "signature": return ; case "stamp": return ; case "pageNumber": return ; case "card": return ; case "calculation": return ; case "barcode": return ; case "checkbox": return ; default: return
알 수 없는 컴포넌트
; } }; return (
{renderContent()} {/* 조건부 표시 미충족 오버레이 (디자인 모드에서는 숨기지 않고 표시) */} {!isConditionalVisible && (
조건부
)} {/* 잠금 표시 */} {isLocked && (
🔒
)} {/* 삭제 버튼 (선택 + 잠금 해제 상태에서만 표시) */} {isSelected && !isLocked && ( )} {/* 그룹화 표시 */} {isGrouped && !isLocked && (
👥
)} {/* 8방향 리사이즈 핸들 */} {isSelected && !isLocked && ( <> {(isDivider ? component.orientation === "vertical" ? RESIZE_HANDLES_VERTICAL_DIVIDER : RESIZE_HANDLES_HORIZONTAL_DIVIDER : RESIZE_HANDLES_ALL ).map((h) => (
handleResizeStart(e, h.dir)} /> ))} )}
); }