From 46aa81ce6f5cfcb1e0964e3892a2c825ec194678 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 1 Oct 2025 15:53:37 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=84=A0=ED=83=9D=20=EB=B0=8F=20=EB=B3=B5?= =?UTF-8?q?=EB=B6=99,=20Re/undo=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 | 12 +- .../report/designer/ReportDesignerCanvas.tsx | 54 ++++- .../report/designer/ReportDesignerToolbar.tsx | 26 ++- frontend/contexts/ReportDesignerContext.tsx | 211 ++++++++++++++++-- 4 files changed, 279 insertions(+), 24 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index f770b4f7..b6a16839 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -11,6 +11,7 @@ interface CanvasComponentProps { export function CanvasComponent({ component }: CanvasComponentProps) { const { selectedComponentId, + selectedComponentIds, selectComponent, updateComponent, getQueryResult, @@ -25,6 +26,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const componentRef = useRef(null); const isSelected = selectedComponentId === component.id; + const isMultiSelected = selectedComponentIds.includes(component.id); // 드래그 시작 const handleMouseDown = (e: React.MouseEvent) => { @@ -33,7 +35,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) { } e.stopPropagation(); - selectComponent(component.id); + + // Ctrl/Cmd 키 감지 (다중 선택) + const isMultiSelect = e.ctrlKey || e.metaKey; + selectComponent(component.id, isMultiSelect); + setIsDragging(true); setDragStart({ x: e.clientX - component.x, @@ -271,7 +277,9 @@ export function CanvasComponent({ component }: CanvasComponentProps) { return (
({ @@ -72,17 +77,58 @@ export function ReportDesignerCanvas() { } }; - // Delete 키 삭제 처리 + // 키보드 단축키 (Delete, Ctrl+C, Ctrl+V) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Delete" && selectedComponentId) { - removeComponent(selectedComponentId); + // 입력 필드에서는 단축키 무시 + const target = e.target as HTMLElement; + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") { + return; + } + + // Delete 키: 삭제 + if (e.key === "Delete") { + if (selectedComponentIds.length > 0) { + selectedComponentIds.forEach((id) => removeComponent(id)); + } else if (selectedComponentId) { + removeComponent(selectedComponentId); + } + } + + // Ctrl+C (또는 Cmd+C): 복사 + if ((e.ctrlKey || e.metaKey) && e.key === "c") { + e.preventDefault(); + copyComponents(); + } + + // Ctrl+V (또는 Cmd+V): 붙여넣기 + if ((e.ctrlKey || e.metaKey) && e.key === "v") { + e.preventDefault(); + pasteComponents(); + } + + // Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크) + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "z") { + e.preventDefault(); + redo(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") { + e.preventDefault(); + redo(); + return; + } + + // Ctrl+Z (또는 Cmd+Z): Undo + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { + e.preventDefault(); + undo(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedComponentId, removeComponent]); + }, [selectedComponentId, selectedComponentIds, removeComponent, copyComponents, pasteComponents, undo, redo]); return (
diff --git a/frontend/components/report/designer/ReportDesignerToolbar.tsx b/frontend/components/report/designer/ReportDesignerToolbar.tsx index 66b50a73..ebb7c7cb 100644 --- a/frontend/components/report/designer/ReportDesignerToolbar.tsx +++ b/frontend/components/report/designer/ReportDesignerToolbar.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { Save, Eye, RotateCcw, ArrowLeft, Loader2, BookTemplate, Grid3x3 } from "lucide-react"; +import { Save, Eye, RotateCcw, ArrowLeft, Loader2, BookTemplate, Grid3x3, Undo2, Redo2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { useState } from "react"; @@ -26,6 +26,10 @@ export function ReportDesignerToolbar() { setSnapToGrid, showGrid, setShowGrid, + undo, + redo, + canUndo, + canRedo, } = useReportDesigner(); const [showPreview, setShowPreview] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); @@ -149,6 +153,26 @@ export function ReportDesignerToolbar() { {snapToGrid && showGrid ? "Grid ON" : "Grid OFF"} + +