키보드를 사용한 복제 및 삭제 구현

This commit is contained in:
dohyeons 2025-10-21 17:42:31 +09:00
parent 8c18555305
commit 76ad3d9c43
3 changed files with 270 additions and 1 deletions

View File

@ -8,11 +8,13 @@ import { ElementConfigModal } from "./ElementConfigModal";
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal";
import { DashboardSaveModal } from "./DashboardSaveModal";
import { KeyboardShortcutsGuide } from "./KeyboardShortcutsGuide";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
import { DashboardProvider } from "@/contexts/DashboardContext";
import { useMenu } from "@/contexts/MenuContext";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
AlertDialog,
@ -25,7 +27,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { CheckCircle2 } from "lucide-react";
import { CheckCircle2, Keyboard } from "lucide-react";
interface DashboardDesignerProps {
dashboardId?: string;
@ -56,6 +58,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const [dashboardDescription, setDashboardDescription] = useState<string>("");
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [shortcutsGuideOpen, setShortcutsGuideOpen] = useState(false);
// 클립보드 (복사/붙여넣기용)
const [clipboard, setClipboard] = useState<DashboardElement | null>(null);
// 화면 해상도 자동 감지
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
@ -289,6 +295,51 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
[selectedElement],
);
// 키보드 단축키 핸들러들
const handleCopyElement = useCallback(() => {
if (!selectedElement) return;
const element = elements.find((el) => el.id === selectedElement);
if (element) {
setClipboard(element);
}
}, [selectedElement, elements]);
const handlePasteElement = useCallback(() => {
if (!clipboard) return;
// 새 ID 생성
const newId = `element-${elementCounter + 1}`;
setElementCounter((prev) => prev + 1);
// 위치를 약간 오프셋 (오른쪽 아래로 20px씩)
const newElement: DashboardElement = {
...clipboard,
id: newId,
position: {
x: clipboard.position.x + 20,
y: clipboard.position.y + 20,
},
};
setElements((prev) => [...prev, newElement]);
setSelectedElement(newId);
}, [clipboard, elementCounter]);
const handleDeleteSelected = useCallback(() => {
if (selectedElement) {
removeElement(selectedElement);
}
}, [selectedElement, removeElement]);
// 키보드 단축키 활성화
useKeyboardShortcuts({
selectedElementId: selectedElement,
onDelete: handleDeleteSelected,
onCopy: handleCopyElement,
onPaste: handlePasteElement,
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen && !shortcutsGuideOpen,
});
// 전체 삭제 확인 모달 열기
const clearCanvas = useCallback(() => {
setClearConfirmOpen(true);
@ -602,6 +653,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 키보드 단축키 가이드 모달 */}
<KeyboardShortcutsGuide isOpen={shortcutsGuideOpen} onClose={() => setShortcutsGuideOpen(false)} />
{/* 키보드 단축키 도움말 플로팅 버튼 */}
<div className="fixed right-8 bottom-8 z-50">
<Button
onClick={() => setShortcutsGuideOpen(true)}
size="lg"
className="h-12 w-12 rounded-full p-0 shadow-lg"
title="키보드 단축키 (?"
>
<Keyboard className="h-5 w-5" />
</Button>
</div>
</div>
</DashboardProvider>
);

View File

@ -0,0 +1,98 @@
"use client";
import React from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Keyboard } from "lucide-react";
interface KeyboardShortcutsGuideProps {
isOpen: boolean;
onClose: () => void;
}
interface ShortcutItem {
keys: string[];
description: string;
category: string;
}
const shortcuts: ShortcutItem[] = [
// 기본 작업
{ keys: ["Delete"], description: "선택한 요소 삭제", category: "기본 작업" },
{ keys: ["Ctrl", "C"], description: "요소 복사", category: "기본 작업" },
{ keys: ["Ctrl", "V"], description: "요소 붙여넣기", category: "기본 작업" },
// 실행 취소/재실행 (구현 예정)
{ keys: ["Ctrl", "Z"], description: "실행 취소 (구현 예정)", category: "편집" },
{ keys: ["Ctrl", "Shift", "Z"], description: "재실행 (구현 예정)", category: "편집" },
];
const KeyBadge = ({ keyName }: { keyName: string }) => (
<kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-gray-300 bg-gray-100 px-2 text-xs font-semibold text-gray-800 shadow-sm">
{keyName}
</kbd>
);
export function KeyboardShortcutsGuide({ isOpen, onClose }: KeyboardShortcutsGuideProps) {
// 카테고리별로 그룹화
const groupedShortcuts = shortcuts.reduce(
(acc, shortcut) => {
if (!acc[shortcut.category]) {
acc[shortcut.category] = [];
}
acc[shortcut.category].push(shortcut);
return acc;
},
{} as Record<string, ShortcutItem[]>,
);
// Mac OS 감지
const isMac = typeof navigator !== "undefined" && navigator.platform.toUpperCase().indexOf("MAC") >= 0;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<div className="flex items-center gap-2">
<Keyboard className="h-5 w-5" />
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</div>
<DialogDescription className="text-xs sm:text-sm">
{isMac && " (Mac에서는 Ctrl 대신 Cmd 키를 사용하세요)"}
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-6 overflow-y-auto pr-2">
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
<div key={category}>
<h3 className="mb-3 text-sm font-semibold text-gray-900">{category}</h3>
<div className="space-y-2">
{categoryShortcuts.map((shortcut, index) => (
<div
key={`${category}-${index}`}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-3"
>
<span className="text-sm text-gray-700">{shortcut.description}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, keyIndex) => (
<React.Fragment key={keyIndex}>
<KeyBadge keyName={isMac && key === "Ctrl" ? "⌘" : key} />
{keyIndex < shortcut.keys.length - 1 && <span className="text-xs text-gray-400">+</span>}
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="mt-4 rounded-lg bg-blue-50 p-3 text-xs text-blue-800">
<p className="font-medium">💡 </p>
<p className="mt-1"> .</p>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,105 @@
import { useEffect, useCallback } from "react";
interface KeyboardShortcutsProps {
selectedElementId: string | null;
onDelete: () => void;
onCopy: () => void;
onPaste: () => void;
onUndo?: () => void;
onRedo?: () => void;
enabled?: boolean;
}
/**
*
*
* :
* - Delete: 선택한
* - Ctrl+C: 요소
* - Ctrl+V: 요소
* - Ctrl+Z: 실행 ( )
* - Ctrl+Shift+Z: 재실행 ( )
*/
export function useKeyboardShortcuts({
selectedElementId,
onDelete,
onCopy,
onPaste,
onUndo,
onRedo,
enabled = true,
}: KeyboardShortcutsProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!enabled) return;
// 입력 필드에서는 단축키 비활성화
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.contentEditable === "true" ||
target.closest('[role="dialog"]') ||
target.closest('[role="alertdialog"]')
) {
return;
}
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
// Delete: 선택한 요소 삭제
if (e.key === "Delete" || e.key === "Backspace") {
if (selectedElementId) {
e.preventDefault();
onDelete();
}
return;
}
// Ctrl+C: 복사
if (ctrlKey && e.key === "c") {
if (selectedElementId) {
e.preventDefault();
onCopy();
}
return;
}
// Ctrl+V: 붙여넣기
if (ctrlKey && e.key === "v") {
e.preventDefault();
onPaste();
return;
}
// Ctrl+Z: 실행 취소
if (ctrlKey && e.key === "z" && !e.shiftKey) {
if (onUndo) {
e.preventDefault();
onUndo();
}
return;
}
// Ctrl+Shift+Z 또는 Ctrl+Y: 재실행
if ((ctrlKey && e.shiftKey && e.key === "z") || (ctrlKey && e.key === "y")) {
if (onRedo) {
e.preventDefault();
onRedo();
}
return;
}
},
[enabled, selectedElementId, onDelete, onCopy, onPaste, onUndo, onRedo],
);
useEffect(() => {
if (!enabled) return;
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyDown, enabled]);
}