ERP-node/frontend/components/dataflow/node-editor/FlowToolbar.tsx

248 lines
7.2 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import {
Save,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
Maximize2,
Download,
Trash2,
Plus,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { useReactFlow } from "reactflow";
import { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog";
import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation";
import { useToast } from "@/hooks/use-toast";
interface FlowToolbarProps {
validations?: FlowValidation[];
onSaveComplete?: (flowId: number, flowName: string) => void;
onOpenCommandPalette?: () => void;
}
export function FlowToolbar({
validations = [],
onSaveComplete,
onOpenCommandPalette,
}: FlowToolbarProps) {
const { toast } = useToast();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const {
flowName,
setFlowName,
nodes,
edges,
saveFlow,
exportFlow,
isSaving,
selectedNodes,
removeNodes,
undo,
redo,
canUndo,
canRedo,
} = useFlowEditorStore();
const [showSaveDialog, setShowSaveDialog] = useState(false);
const handleSaveRef = useRef<() => void>();
useEffect(() => {
handleSaveRef.current = handleSave;
});
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (!isSaving) handleSaveRef.current?.();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isSaving]);
const handleSave = async () => {
const currentValidations =
validations.length > 0 ? validations : validateFlow(nodes, edges);
if (currentValidations.length > 0) {
setShowSaveDialog(true);
return;
}
await performSave();
};
const performSave = async () => {
const result = await saveFlow();
if (result.success) {
toast({
title: "저장했어요",
description: `플로우가 안전하게 저장됐어요`,
variant: "default",
});
if (onSaveComplete && result.flowId) {
onSaveComplete(result.flowId, flowName);
}
if (window.opener && result.flowId) {
window.opener.postMessage(
{ type: "FLOW_SAVED", flowId: result.flowId, flowName },
"*",
);
}
} else {
toast({
title: "저장에 실패했어요",
description: result.message,
variant: "destructive",
});
}
setShowSaveDialog(false);
};
const handleExport = () => {
const json = exportFlow();
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${flowName || "flow"}.json`;
a.click();
URL.revokeObjectURL(url);
toast({
title: "내보내기 완료",
description: "JSON 파일로 저장했어요",
variant: "default",
});
};
const handleDelete = () => {
if (selectedNodes.length === 0) return;
removeNodes(selectedNodes);
toast({
title: "노드를 삭제했어요",
description: `${selectedNodes.length}개 노드가 삭제됐어요`,
variant: "default",
});
};
const ToolBtn = ({
onClick,
disabled,
title,
danger,
children,
}: {
onClick: () => void;
disabled?: boolean;
title: string;
danger?: boolean;
children: React.ReactNode;
}) => (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors disabled:opacity-30 ${
danger
? "text-pink-400 hover:bg-pink-500/15"
: "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
}`}
>
{children}
</button>
);
return (
<>
<div className="flex items-center gap-1 rounded-xl border border-zinc-700 bg-zinc-900/95 px-2 py-1.5 shadow-lg shadow-black/30 backdrop-blur-sm">
{/* 노드 추가 */}
{onOpenCommandPalette && (
<>
<button
onClick={onOpenCommandPalette}
title="노드 추가 (/)"
className="flex h-8 items-center gap-1.5 rounded-lg bg-violet-600/20 px-2.5 text-violet-400 transition-colors hover:bg-violet-600/30"
>
<Plus className="h-3.5 w-3.5" />
<span className="text-xs font-medium"></span>
</button>
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
</>
)}
{/* 플로우 이름 */}
<Input
value={flowName}
onChange={(e) => setFlowName(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
className="h-7 w-[160px] border-none bg-transparent px-2 text-xs font-medium text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="플로우 이름을 입력해요"
/>
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
{/* Undo / Redo */}
<ToolBtn onClick={undo} disabled={!canUndo()} title="실행 취소 (Ctrl+Z)">
<Undo2 className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn onClick={redo} disabled={!canRedo()} title="다시 실행 (Ctrl+Y)">
<Redo2 className="h-3.5 w-3.5" />
</ToolBtn>
{/* 삭제 */}
{selectedNodes.length > 0 && (
<>
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
<ToolBtn onClick={handleDelete} title={`${selectedNodes.length}개 삭제`} danger>
<Trash2 className="h-3.5 w-3.5" />
</ToolBtn>
</>
)}
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
{/* 줌 */}
<ToolBtn onClick={() => zoomIn()} title="확대">
<ZoomIn className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn onClick={() => zoomOut()} title="축소">
<ZoomOut className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn onClick={() => fitView()} title="전체 보기">
<Maximize2 className="h-3.5 w-3.5" />
</ToolBtn>
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
{/* 저장 */}
<button
onClick={handleSave}
disabled={isSaving}
title="저장 (Ctrl+S)"
className="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100 disabled:opacity-40"
>
<Save className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{isSaving ? "저장 중..." : "저장"}</span>
</button>
{/* JSON 내보내기 */}
<ToolBtn onClick={handleExport} title="JSON 내보내기">
<Download className="h-3.5 w-3.5" />
</ToolBtn>
</div>
<SaveConfirmDialog
open={showSaveDialog}
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}
onConfirm={performSave}
onCancel={() => setShowSaveDialog(false)}
/>
</>
);
}