컴포넌트 다중 선택 및 복붙, Re/undo 구현

This commit is contained in:
dohyeons
2025-10-01 15:53:37 +09:00
parent 771dc8cf56
commit 46aa81ce6f
4 changed files with 279 additions and 24 deletions

View File

@@ -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<HTMLDivElement>(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 (
<div
ref={componentRef}
className={`absolute cursor-move p-2 shadow-sm ${isSelected ? "ring-2 ring-blue-500" : ""}`}
className={`absolute cursor-move p-2 shadow-sm ${
isSelected ? "ring-2 ring-blue-500" : isMultiSelected ? "ring-2 ring-blue-300" : ""
}`}
style={{
left: `${component.x}px`,
top: `${component.y}px`,

View File

@@ -16,11 +16,16 @@ export function ReportDesignerCanvas() {
canvasHeight,
selectComponent,
selectedComponentId,
selectedComponentIds,
removeComponent,
showGrid,
gridSize,
snapValueToGrid,
alignmentGuides,
copyComponents,
pasteComponents,
undo,
redo,
} = useReportDesigner();
const [{ isOver }, drop] = useDrop(() => ({
@@ -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 (
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">

View File

@@ -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() {
<Grid3x3 className="h-4 w-4" />
{snapToGrid && showGrid ? "Grid ON" : "Grid OFF"}
</Button>
<Button
variant="outline"
size="sm"
onClick={undo}
disabled={!canUndo}
className="gap-2"
title="실행 취소 (Ctrl+Z)"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={redo}
disabled={!canRedo}
className="gap-2"
title="다시 실행 (Ctrl+Shift+Z)"
>
<Redo2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
<RotateCcw className="h-4 w-4" />