"use client"; import { useRef, useState, useEffect } from "react"; import { ComponentConfig } from "@/types/report"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { getFullImageUrl } from "@/lib/api/client"; import JsBarcode from "jsbarcode"; import QRCode from "qrcode"; // 고정 스케일 팩터 (화면 해상도와 무관) const MM_TO_PX = 4; // 1D 바코드 렌더러 컴포넌트 interface BarcodeRendererProps { value: string; format: string; width: number; height: number; displayValue: boolean; lineColor: string; background: string; margin: number; } function BarcodeRenderer({ value, format, width, height, displayValue, lineColor, background, margin, }: BarcodeRendererProps) { const svgRef = useRef(null); const [error, setError] = useState(null); useEffect(() => { if (!svgRef.current || !value) return; // 매번 에러 상태 초기화 후 재검사 setError(null); try { // 바코드 형식에 따른 유효성 검사 let isValid = true; let errorMsg = ""; const trimmedValue = value.trim(); if (format === "EAN13" && !/^\d{12,13}$/.test(trimmedValue)) { isValid = false; errorMsg = "EAN-13: 12~13자리 숫자 필요"; } else if (format === "EAN8" && !/^\d{7,8}$/.test(trimmedValue)) { isValid = false; errorMsg = "EAN-8: 7~8자리 숫자 필요"; } else if (format === "UPC" && !/^\d{11,12}$/.test(trimmedValue)) { isValid = false; errorMsg = "UPC: 11~12자리 숫자 필요"; } if (!isValid) { setError(errorMsg); return; } // JsBarcode는 format을 소문자로 받음 const barcodeFormat = format.toLowerCase(); // transparent는 빈 문자열로 변환 (SVG 배경 없음) const bgColor = background === "transparent" ? "" : background; JsBarcode(svgRef.current, trimmedValue, { format: barcodeFormat, width: 2, height: Math.max(30, height - (displayValue ? 30 : 10)), displayValue: displayValue, lineColor: lineColor, background: bgColor, margin: margin, fontSize: 12, textMargin: 2, }); } catch (err: any) { // JsBarcode 체크섬 오류 등 setError(err?.message || "바코드 생성 실패"); } }, [value, format, width, height, displayValue, lineColor, background, margin]); return (
{/* SVG는 항상 렌더링 (에러 시 숨김) */} {/* 에러 메시지 오버레이 */} {error && (
{error} {value}
)}
); } // QR코드 렌더러 컴포넌트 interface QRCodeRendererProps { value: string; size: number; fgColor: string; bgColor: string; level: "L" | "M" | "Q" | "H"; } function QRCodeRenderer({ value, size, fgColor, bgColor, level }: QRCodeRendererProps) { const canvasRef = useRef(null); const [error, setError] = useState(null); useEffect(() => { if (!canvasRef.current || !value) return; // 매번 에러 상태 초기화 후 재시도 setError(null); // qrcode 라이브러리는 hex 색상만 지원, transparent는 흰색으로 대체 const lightColor = bgColor === "transparent" ? "#ffffff" : bgColor; QRCode.toCanvas( canvasRef.current, value, { width: Math.max(50, size), margin: 2, color: { dark: fgColor, light: lightColor, }, errorCorrectionLevel: level, }, (err) => { if (err) { // 실제 에러 메시지 표시 setError(err.message || "QR코드 생성 실패"); } }, ); }, [value, size, fgColor, bgColor, level]); return (
{/* Canvas는 항상 렌더링 (에러 시 숨김) */} {/* 에러 메시지 오버레이 */} {error && (
{error} {value}
)}
); } 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, } = useReportDesigner(); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 }); const componentRef = useRef(null); // Alt+드래그 복제를 위한 상태 const [isAltDuplicating, setIsAltDuplicating] = useState(false); const duplicatedIdsRef = useRef([]); // 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용) const originalPositionsRef = useRef>(new Map()); // 인라인 편집 상태 const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(""); const textareaRef = useRef(null); 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 = "system-ui, -apple-system, sans-serif"; 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), }); }; // 더블 클릭 핸들러 (텍스트 컴포넌트: 인라인 편집 모드 진입) const handleDoubleClick = (e: React.MouseEvent) => { if (component.type !== "text" && component.type !== "label") return; if (isLocked) return; // 잠긴 컴포넌트는 편집 불가 e.stopPropagation(); // 인라인 편집 모드 진입 setEditValue(component.defaultValue || ""); setIsEditing(true); }; // 인라인 편집 시작 시 textarea에 포커스 useEffect(() => { if (isEditing && textareaRef.current) { textareaRef.current.focus(); textareaRef.current.select(); } }, [isEditing]); // 선택 해제 시 편집 모드 종료를 위한 ref const editValueRef = useRef(editValue); const isEditingRef = useRef(isEditing); editValueRef.current = editValue; isEditingRef.current = isEditing; // 선택 해제 시 편집 모드 종료 (저장 후 종료) useEffect(() => { if (!isSelected && !isMultiSelected && isEditingRef.current) { // 현재 편집 값으로 저장 if (editValueRef.current !== component.defaultValue) { updateComponent(component.id, { defaultValue: editValueRef.current }); } setIsEditing(false); } }, [isSelected, isMultiSelected, component.id, component.defaultValue, updateComponent]); // 인라인 편집 저장 const handleEditSave = () => { if (!isEditing) return; updateComponent(component.id, { defaultValue: editValue, }); setIsEditing(false); }; // 인라인 편집 취소 const handleEditCancel = () => { setIsEditing(false); setEditValue(""); }; // 인라인 편집 키보드 핸들러 const handleEditKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); handleEditCancel(); } else if (e.key === "Enter" && !e.shiftKey) { // Enter: 저장 (Shift+Enter는 줄바꿈) e.preventDefault(); handleEditSave(); } }; // 드래그 시작 const handleMouseDown = (e: React.MouseEvent) => { // 편집 모드에서는 드래그 비활성화 if (isEditing) return; 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); setDragStart({ x: e.clientX - component.x, y: e.clientY - component.y, }); }; // 리사이즈 시작 const handleResizeStart = (e: React.MouseEvent) => { // 잠긴 컴포넌트는 리사이즈 불가 if (isLocked) { e.stopPropagation(); return; } e.stopPropagation(); setIsResizing(true); setResizeStart({ x: e.clientX, y: e.clientY, width: component.width, height: component.height, }); }; // 마우스 이동 핸들러 (전역) 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 newWidth = Math.max(50, resizeStart.width + deltaX); const newHeight = Math.max(30, resizeStart.height + deltaY); // 여백을 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 boundedWidth = Math.min(newWidth, maxWidth); const boundedHeight = Math.min(newHeight, maxHeight); // 구분선은 방향에 따라 한 축만 조절 가능 if (component.type === "divider") { if (component.orientation === "vertical") { // 세로 구분선: 높이만 조절 updateComponent(component.id, { height: snapValueToGrid(boundedHeight), }); } else { // 가로 구분선: 너비만 조절 updateComponent(component.id, { width: snapValueToGrid(boundedWidth), }); } } else if (component.type === "barcode" && component.barcodeType === "QR") { // QR코드는 정사각형 유지: 더 큰 변화량 기준으로 동기화 const maxDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY; const newSize = Math.max(50, resizeStart.width + maxDelta); const maxSize = Math.min( canvasWidthPx - marginRightPx - component.x, canvasHeightPx - marginBottomPx - component.y, ); const boundedSize = Math.min(newSize, maxSize); const snappedSize = snapValueToGrid(boundedSize); updateComponent(component.id, { width: snappedSize, height: snappedSize, }); } else { // Grid Snap 적용 updateComponent(component.id, { width: snapValueToGrid(boundedWidth), height: snapValueToGrid(boundedHeight), }); } } }; 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, dragStart.x, dragStart.y, resizeStart.x, resizeStart.y, resizeStart.width, resizeStart.height, component.id, component.x, component.y, component.width, component.height, component.groupId, isGrouped, components, updateComponent, snapValueToGrid, calculateAlignmentGuides, clearAlignmentGuides, canvasWidth, canvasHeight, ]); // 컴포넌트 타입별 렌더링 const renderContent = () => { const displayValue = getDisplayValue(); const hasBinding = component.queryId && component.fieldName; switch (component.type) { case "text": case "label": // 인라인 편집 모드 if (isEditing) { return (