From aa283d11dac7406aaac9e9c3021fa11fca3305ee Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 24 Dec 2025 09:48:37 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=8D=94=EB=B8=94=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=8B=9C=20=EC=BB=A8=ED=85=90=EC=B8=A0=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 130 ++++++++++++++---- 1 file changed, 100 insertions(+), 30 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index ccc3aa8a..8f7c1db2 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -190,6 +190,105 @@ export function CanvasComponent({ component }: CanvasComponentProps) { 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; + e.stopPropagation(); + fitTextToContent(); + }; + // 드래그 시작 const handleMouseDown = (e: React.MouseEvent) => { if ((e.target as HTMLElement).classList.contains("resize-handle")) { @@ -405,36 +504,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(); @@ -1182,6 +1251,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { : "1px solid #e5e7eb", }} onMouseDown={handleMouseDown} + onDoubleClick={handleDoubleClick} > {renderContent()} From 352f9f441fe49cf867de29c08b903540af2855dd Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 24 Dec 2025 10:10:52 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EC=84=A0=ED=83=9D(Marquee=20Selection)=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/ReportDesignerCanvas.tsx | 152 +++++++++++++++++- frontend/contexts/ReportDesignerContext.tsx | 13 ++ 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index 6684047b..e795c2c9 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState } from "react"; import { useDrop } from "react-dnd"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { ComponentConfig, WatermarkConfig } from "@/types/report"; @@ -201,6 +201,7 @@ export function ReportDesignerCanvas() { canvasHeight, margins, selectComponent, + selectMultipleComponents, selectedComponentId, selectedComponentIds, removeComponent, @@ -216,6 +217,22 @@ export function ReportDesignerCanvas() { 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) => { @@ -420,12 +437,127 @@ export function ReportDesignerCanvas() { }), })); + // 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만) const handleCanvasClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { + // 마퀴 선택 직후의 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) => { @@ -592,8 +724,10 @@ export function ReportDesignerCanvas() { ` : undefined, backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined, + cursor: isMarqueeSelecting ? "crosshair" : "default", }} onClick={handleCanvasClick} + onMouseDown={handleCanvasMouseDown} > {/* 페이지 여백 가이드 */} {currentPage && ( @@ -648,6 +782,20 @@ export function ReportDesignerCanvas() { ))} + {/* 드래그 영역 선택 사각형 */} + {isMarqueeSelecting && ( +
+ )} + {/* 빈 캔버스 안내 */} {components.length === 0 && (
diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index 3db07bc9..098e419e 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -63,6 +63,7 @@ interface ReportDesignerContextType { updateComponent: (id: string, updates: Partial) => void; removeComponent: (id: string) => void; selectComponent: (id: string | null, isMultiSelect?: boolean) => void; + selectMultipleComponents: (ids: string[]) => void; // 여러 컴포넌트 한번에 선택 // 레이아웃 관리 updateLayout: (updates: Partial) => void; @@ -1344,6 +1345,17 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin } }, []); + // 여러 컴포넌트 한번에 선택 (마퀴 선택용) + const selectMultipleComponents = useCallback((ids: string[]) => { + if (ids.length === 0) { + setSelectedComponentId(null); + setSelectedComponentIds([]); + return; + } + setSelectedComponentId(ids[0]); + setSelectedComponentIds(ids); + }, []); + // 레이아웃 업데이트 const updateLayout = useCallback( (updates: Partial) => { @@ -1639,6 +1651,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin updateComponent, removeComponent, selectComponent, + selectMultipleComponents, updateLayout, saveLayout, loadLayout, From a299195b428cf8f1c18214cb589121c9253471a6 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 24 Dec 2025 10:16:37 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=9B=84=20=ED=95=A8=EA=BB=98=20=EC=9D=B4=EB=8F=99=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 8f7c1db2..ea84b81e 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -309,15 +309,20 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // Ctrl/Cmd 키 감지 (다중 선택) const isMultiSelect = e.ctrlKey || e.metaKey; - // 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택 - 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); + } } setIsDragging(true); @@ -389,8 +394,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) { 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; From 386ce629ace0aa4b563c44a5b6359cbcbc04e1d0 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 24 Dec 2025 10:20:21 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20=EC=9E=A0=EA=B8=88=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B3=B5=EC=82=AC=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/contexts/ReportDesignerContext.tsx | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index 098e419e..a55a1c6d 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -285,7 +285,18 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin // 복사 (Ctrl+C) const copyComponents = useCallback(() => { if (selectedComponentIds.length > 0) { - const componentsToCopy = components.filter((comp) => selectedComponentIds.includes(comp.id)); + // 잠긴 컴포넌트는 복사에서 제외 + const componentsToCopy = components.filter( + (comp) => selectedComponentIds.includes(comp.id) && !comp.locked + ); + if (componentsToCopy.length === 0) { + toast({ + title: "복사 불가", + description: "잠긴 컴포넌트는 복사할 수 없습니다.", + variant: "destructive", + }); + return; + } setClipboard(componentsToCopy); toast({ title: "복사 완료", @@ -294,6 +305,15 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin } else if (selectedComponentId) { const componentToCopy = components.find((comp) => comp.id === selectedComponentId); if (componentToCopy) { + // 잠긴 컴포넌트는 복사 불가 + if (componentToCopy.locked) { + toast({ + title: "복사 불가", + description: "잠긴 컴포넌트는 복사할 수 없습니다.", + variant: "destructive", + }); + return; + } setClipboard([componentToCopy]); toast({ title: "복사 완료", From f300b637d10b60cd2d19fb287043519f950a0ac0 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 24 Dec 2025 10:42:34 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=EB=B3=B5=EC=A0=9C=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EB=B3=B5=EC=82=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 100 +++++++++ .../report/designer/ReportDesignerCanvas.tsx | 33 ++- frontend/contexts/ReportDesignerContext.tsx | 194 ++++++++++++++++++ 3 files changed, 325 insertions(+), 2 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index ea84b81e..1bd6db73 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,12 +179,19 @@ 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 isSelected = selectedComponentId === component.id; const isMultiSelected = selectedComponentIds.includes(component.id); @@ -308,6 +316,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // Ctrl/Cmd 키 감지 (다중 선택) const isMultiSelect = e.ctrlKey || e.metaKey; + // Alt 키 감지 (복제 드래그) + const isAltPressed = e.altKey; // 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작) const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id); @@ -325,6 +335,66 @@ export function CanvasComponent({ component }: CanvasComponentProps) { } } + // 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, @@ -388,6 +458,31 @@ 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, @@ -492,6 +587,10 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const handleMouseUp = () => { setIsDragging(false); setIsResizing(false); + // Alt 복제 상태 초기화 + setIsAltDuplicating(false); + duplicatedIdsRef.current = []; + originalPositionsRef.current = new Map(); // 가이드라인 초기화 clearAlignmentGuides(); }; @@ -506,6 +605,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { }, [ isDragging, isResizing, + isAltDuplicating, dragStart.x, dragStart.y, resizeStart.x, diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index e795c2c9..c3bd337a 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -211,6 +211,9 @@ export function ReportDesignerCanvas() { alignmentGuides, copyComponents, pasteComponents, + duplicateComponents, + copyStyles, + pasteStyles, undo, redo, showRuler, @@ -629,16 +632,39 @@ export function ReportDesignerCanvas() { } } + // 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+C (또는 Cmd+C): 복사 - if ((e.ctrlKey || e.metaKey) && e.key === "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 === "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보다 먼저 체크) @@ -670,6 +696,9 @@ export function ReportDesignerCanvas() { removeComponent, copyComponents, pasteComponents, + duplicateComponents, + copyStyles, + pasteStyles, undo, redo, ]); diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index a55a1c6d..b58a6f69 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -101,6 +101,10 @@ interface ReportDesignerContextType { // 복사/붙여넣기 copyComponents: () => void; pasteComponents: () => void; + duplicateComponents: () => void; // Ctrl+D 즉시 복제 + copyStyles: () => void; // Ctrl+Shift+C 스타일만 복사 + pasteStyles: () => void; // Ctrl+Shift+V 스타일만 붙여넣기 + duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; // Alt+드래그 복제용 // Undo/Redo undo: () => void; @@ -268,6 +272,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin // 클립보드 (복사/붙여넣기) const [clipboard, setClipboard] = useState([]); + // 스타일 클립보드 (스타일만 복사/붙여넣기) + const [styleClipboard, setStyleClipboard] = useState | null>(null); + // Undo/Redo 히스토리 const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); @@ -353,6 +360,189 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin }); }, [clipboard, components.length, toast]); + // 복제 (Ctrl+D) - 선택된 컴포넌트를 즉시 복제 + const duplicateComponents = useCallback(() => { + // 복제할 컴포넌트 결정 + let componentsToDuplicate: ComponentConfig[] = []; + + if (selectedComponentIds.length > 0) { + componentsToDuplicate = components.filter( + (comp) => selectedComponentIds.includes(comp.id) && !comp.locked + ); + } else if (selectedComponentId) { + const comp = components.find((c) => c.id === selectedComponentId); + if (comp && !comp.locked) { + componentsToDuplicate = [comp]; + } + } + + if (componentsToDuplicate.length === 0) { + toast({ + title: "복제 불가", + description: "복제할 컴포넌트가 없거나 잠긴 컴포넌트입니다.", + variant: "destructive", + }); + return; + } + + const newComponents = componentsToDuplicate.map((comp) => ({ + ...comp, + id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + x: comp.x + 20, + y: comp.y + 20, + zIndex: components.length, + locked: false, // 복제된 컴포넌트는 잠금 해제 + })); + + setComponents((prev) => [...prev, ...newComponents]); + + // 복제된 컴포넌트 선택 + if (newComponents.length === 1) { + setSelectedComponentId(newComponents[0].id); + setSelectedComponentIds([newComponents[0].id]); + } else { + setSelectedComponentIds(newComponents.map((c) => c.id)); + setSelectedComponentId(newComponents[0].id); + } + + toast({ + title: "복제 완료", + description: `${newComponents.length}개의 컴포넌트가 복제되었습니다.`, + }); + }, [selectedComponentId, selectedComponentIds, components, toast]); + + // 스타일 복사 (Ctrl+Shift+C) + const copyStyles = useCallback(() => { + // 단일 컴포넌트만 스타일 복사 가능 + const targetId = selectedComponentId || selectedComponentIds[0]; + if (!targetId) { + toast({ + title: "스타일 복사 불가", + description: "컴포넌트를 선택해주세요.", + variant: "destructive", + }); + return; + } + + const component = components.find((c) => c.id === targetId); + if (!component) return; + + // 스타일 관련 속성만 추출 + const styleProperties: Partial = { + fontSize: component.fontSize, + fontColor: component.fontColor, + fontWeight: component.fontWeight, + fontFamily: component.fontFamily, + textAlign: component.textAlign, + backgroundColor: component.backgroundColor, + borderWidth: component.borderWidth, + borderColor: component.borderColor, + borderStyle: component.borderStyle, + borderRadius: component.borderRadius, + boxShadow: component.boxShadow, + opacity: component.opacity, + padding: component.padding, + letterSpacing: component.letterSpacing, + lineHeight: component.lineHeight, + }; + + // undefined 값 제거 + Object.keys(styleProperties).forEach((key) => { + if (styleProperties[key as keyof typeof styleProperties] === undefined) { + delete styleProperties[key as keyof typeof styleProperties]; + } + }); + + setStyleClipboard(styleProperties); + toast({ + title: "스타일 복사 완료", + description: "스타일이 복사되었습니다. Ctrl+Shift+V로 적용할 수 있습니다.", + }); + }, [selectedComponentId, selectedComponentIds, components, toast]); + + // 스타일 붙여넣기 (Ctrl+Shift+V) + const pasteStyles = useCallback(() => { + if (!styleClipboard) { + toast({ + title: "스타일 붙여넣기 불가", + description: "먼저 Ctrl+Shift+C로 스타일을 복사해주세요.", + variant: "destructive", + }); + return; + } + + // 선택된 컴포넌트들에 스타일 적용 + const targetIds = + selectedComponentIds.length > 0 + ? selectedComponentIds + : selectedComponentId + ? [selectedComponentId] + : []; + + if (targetIds.length === 0) { + toast({ + title: "스타일 붙여넣기 불가", + description: "스타일을 적용할 컴포넌트를 선택해주세요.", + variant: "destructive", + }); + return; + } + + // 잠긴 컴포넌트 필터링 + const applicableIds = targetIds.filter((id) => { + const comp = components.find((c) => c.id === id); + return comp && !comp.locked; + }); + + if (applicableIds.length === 0) { + toast({ + title: "스타일 붙여넣기 불가", + description: "잠긴 컴포넌트에는 스타일을 적용할 수 없습니다.", + variant: "destructive", + }); + return; + } + + setComponents((prev) => + prev.map((comp) => { + if (applicableIds.includes(comp.id)) { + return { ...comp, ...styleClipboard }; + } + return comp; + }) + ); + + toast({ + title: "스타일 적용 완료", + description: `${applicableIds.length}개의 컴포넌트에 스타일이 적용되었습니다.`, + }); + }, [styleClipboard, selectedComponentId, selectedComponentIds, components, toast]); + + // Alt+드래그 복제용: 지정된 위치에 컴포넌트 복제 + const duplicateAtPosition = useCallback( + (componentIds: string[], offsetX: number = 0, offsetY: number = 0): string[] => { + const componentsToDuplicate = components.filter( + (comp) => componentIds.includes(comp.id) && !comp.locked + ); + + if (componentsToDuplicate.length === 0) return []; + + const newComponents = componentsToDuplicate.map((comp) => ({ + ...comp, + id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + x: comp.x + offsetX, + y: comp.y + offsetY, + zIndex: components.length, + locked: false, + })); + + setComponents((prev) => [...prev, ...newComponents]); + + return newComponents.map((c) => c.id); + }, + [components] + ); + // 히스토리에 현재 상태 저장 const saveToHistory = useCallback( (newComponents: ComponentConfig[]) => { @@ -1695,6 +1885,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin // 복사/붙여넣기 copyComponents, pasteComponents, + duplicateComponents, + copyStyles, + pasteStyles, + duplicateAtPosition, // Undo/Redo undo, redo, From c20e393a1a930741147069a082f1af1ddc49ee67 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 24 Dec 2025 10:58:41 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=ED=8E=B8=EC=A7=91=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 91 ++++++++++++++- .../report/designer/ReportDesignerCanvas.tsx | 9 ++ frontend/contexts/ReportDesignerContext.tsx | 110 ++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 1bd6db73..da440abc 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -193,6 +193,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용) 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; @@ -290,15 +295,76 @@ export function CanvasComponent({ component }: CanvasComponentProps) { }); }; - // 더블 클릭 핸들러 (텍스트 컴포넌트만) + // 더블 클릭 핸들러 (텍스트 컴포넌트: 인라인 편집 모드 진입) const handleDoubleClick = (e: React.MouseEvent) => { if (component.type !== "text" && component.type !== "label") return; + if (isLocked) return; // 잠긴 컴포넌트는 편집 불가 + e.stopPropagation(); - fitTextToContent(); + + // 인라인 편집 모드 진입 + 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; } @@ -636,6 +702,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) { switch (component.type) { case "text": case "label": + // 인라인 편집 모드 + if (isEditing) { + return ( +