248 lines
7.2 KiB
TypeScript
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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|