diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index ccc3aa8a..da440abc 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -168,6 +168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { selectedComponentId, selectedComponentIds, selectComponent, + selectMultipleComponents, updateComponent, getQueryResult, snapValueToGrid, @@ -178,20 +179,192 @@ export function CanvasComponent({ component }: CanvasComponentProps) { 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; } @@ -209,16 +382,83 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // Ctrl/Cmd 키 감지 (다중 선택) const isMultiSelect = e.ctrlKey || e.metaKey; + // Alt 키 감지 (복제 드래그) + const isAltPressed = e.altKey; - // 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택 - 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); + // 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작) + 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); @@ -284,14 +524,58 @@ export function CanvasComponent({ component }: CanvasComponentProps) { 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), + }); + } + }); + } // 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동 - if (isGrouped) { + else if (isGrouped) { components.forEach((c) => { if (c.groupId === component.groupId && c.id !== component.id) { const newGroupX = c.x + deltaX; @@ -369,6 +653,10 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const handleMouseUp = () => { setIsDragging(false); setIsResizing(false); + // Alt 복제 상태 초기화 + setIsAltDuplicating(false); + duplicatedIdsRef.current = []; + originalPositionsRef.current = new Map(); // 가이드라인 초기화 clearAlignmentGuides(); }; @@ -383,6 +671,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { }, [ isDragging, isResizing, + isAltDuplicating, dragStart.x, dragStart.y, resizeStart.x, @@ -405,36 +694,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) { canvasHeight, ]); - // 표시할 값 결정 - 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 renderContent = () => { const displayValue = getDisplayValue(); @@ -443,6 +702,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) { switch (component.type) { case "text": case "label": + // 인라인 편집 모드 + if (isEditing) { + return ( +