From f300b637d10b60cd2d19fb287043519f950a0ac0 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 24 Dec 2025 10:42:34 +0900 Subject: [PATCH] =?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,