You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
320 lines
12 KiB
320 lines
12 KiB
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { FolderOpenOutlined, NodeIndexOutlined, PictureOutlined, PlusOutlined } from "@ant-design/icons";
|
|
import { useWorkflowStore } from "@/store/workflowStore";
|
|
import { NodeType } from "@/types";
|
|
import { useReactFlow } from "@xyflow/react";
|
|
import { useI18n } from "@/i18n";
|
|
import { buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel";
|
|
import { GenerateNodeMenu } from "./GenerateNodeMenu";
|
|
import { AssetLibraryDrawer } from "./AssetLibraryDrawer";
|
|
import { CanvasOutlineDrawer } from "./CanvasOutlineDrawer";
|
|
import { MyCreationPickerModal } from "./MyCreationPickerModal";
|
|
import { useFTUXStore, TutorialStep } from "@/store/ftuxStore";
|
|
|
|
// Get the center of the React Flow pane in screen coordinates
|
|
function getPaneCenter() {
|
|
const pane = document.querySelector(".react-flow");
|
|
if (pane) {
|
|
const rect = pane.getBoundingClientRect();
|
|
return {
|
|
x: rect.left + rect.width / 2,
|
|
y: rect.top + rect.height / 2,
|
|
};
|
|
}
|
|
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
|
}
|
|
|
|
function GenerateComboButton() {
|
|
const { t } = useI18n();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const addNode = useWorkflowStore((state) => state.addNode);
|
|
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
|
|
const { screenToFlowPosition } = useReactFlow();
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
const handleAddNode = async (type: NodeType) => {
|
|
const center = getPaneCenter();
|
|
const position = screenToFlowPosition({
|
|
x: center.x + Math.random() * 100 - 50,
|
|
y: center.y + Math.random() * 100 - 50,
|
|
});
|
|
|
|
const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type));
|
|
selectSingleNode(nodeId);
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const handleDragStart = (event: React.DragEvent, type: NodeType) => {
|
|
event.dataTransfer.setData("application/node-type", type);
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
setIsOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className="relative" ref={menuRef}>
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
title={t("toolbar.addNode")}
|
|
aria-label={t("toolbar.addNode")}
|
|
aria-expanded={isOpen}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--text-secondary)] text-[var(--text-inverse)] shadow-sm transition-colors hover:bg-[var(--text-primary)]"
|
|
>
|
|
<span className={`text-[18px] leading-none transition-transform duration-200 ${isOpen ? "rotate-45" : "rotate-0"}`}>
|
|
<PlusOutlined />
|
|
</span>
|
|
<span className="sr-only">{t("toolbar.addNode")}</span>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<GenerateNodeMenu
|
|
draggable
|
|
onSelect={handleAddNode}
|
|
onDragStart={handleDragStart}
|
|
className="absolute left-full top-0 ml-2 min-w-[140px] overflow-visible outline-none"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MyCreationButton() {
|
|
const { t } = useI18n();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setIsOpen(true)}
|
|
title={t("myCreation.title")}
|
|
aria-label={t("myCreation.title")}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
<PictureOutlined className="text-[18px]" />
|
|
<span className="sr-only">{t("myCreation.title")}</span>
|
|
</button>
|
|
|
|
{isOpen && <MyCreationPickerModal onClose={() => setIsOpen(false)} />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AssetLibraryButton() {
|
|
const { t } = useI18n();
|
|
const [isAssetLibraryOpen, setIsAssetLibraryOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setIsAssetLibraryOpen(true)}
|
|
title={t("assetLibrary.title")}
|
|
aria-label={t("assetLibrary.title")}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
<FolderOpenOutlined className="text-[18px]" />
|
|
<span className="sr-only">{t("assetLibrary.title")}</span>
|
|
</button>
|
|
|
|
<AssetLibraryDrawer
|
|
open={isAssetLibraryOpen}
|
|
onClose={() => setIsAssetLibraryOpen(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CanvasOutlineButton() {
|
|
const { t } = useI18n();
|
|
const [isOutlineOpen, setIsOutlineOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setIsOutlineOpen(true)}
|
|
title={t("canvasOutline.title")}
|
|
aria-label={t("canvasOutline.title")}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
<NodeIndexOutlined className="text-[18px]" />
|
|
<span className="sr-only">{t("canvasOutline.title")}</span>
|
|
</button>
|
|
|
|
{isOutlineOpen && (
|
|
<CanvasOutlineDrawer
|
|
open={isOutlineOpen}
|
|
onClose={() => setIsOutlineOpen(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function FloatingActionBar() {
|
|
const { t } = useI18n();
|
|
const nodes = useWorkflowStore((state) => state.nodes) ?? [];
|
|
const isRunning = useWorkflowStore((state) => state.isRunning) ?? false;
|
|
const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds) ?? [];
|
|
const executeWorkflow = useWorkflowStore((state) => state.executeWorkflow) ?? (async () => {});
|
|
const regenerateNode = useWorkflowStore((state) => state.regenerateNode) ?? (async () => {});
|
|
const executeSelectedNodes = useWorkflowStore((state) => state.executeSelectedNodes) ?? (async () => {});
|
|
const stopWorkflow = useWorkflowStore((state) => state.stopWorkflow) ?? (() => {});
|
|
const mockTutorialExecution = useWorkflowStore((state) => state.mockTutorialExecution) ?? (async () => {});
|
|
const validateWorkflow = useWorkflowStore((state) => state.validateWorkflow);
|
|
const edgeStyle = useWorkflowStore((state) => state.edgeStyle);
|
|
const setEdgeStyle = useWorkflowStore((state) => state.setEdgeStyle);
|
|
const [tutorialActive, setTutorialActive] = useState(false);
|
|
const [currentTutorialStep, setCurrentTutorialStep] = useState(0);
|
|
const [tutorialSteps, setTutorialSteps] = useState<TutorialStep[]>([]);
|
|
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
|
const runMenuRef = useRef<HTMLDivElement>(null);
|
|
const { valid, errors } = validateWorkflow?.() ?? { valid: true, errors: [] };
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = useFTUXStore.subscribe((state) => {
|
|
setTutorialActive(state.tutorialActive);
|
|
setCurrentTutorialStep(state.currentTutorialStep);
|
|
setTutorialSteps(state.tutorialSteps);
|
|
});
|
|
|
|
const currentState = useFTUXStore.getState();
|
|
setTutorialActive(currentState.tutorialActive);
|
|
setCurrentTutorialStep(currentState.currentTutorialStep);
|
|
setTutorialSteps(currentState.tutorialSteps);
|
|
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
const selectedNodes = useMemo(() => nodes.filter((node) => node.selected), [nodes]);
|
|
const selectedNode = useMemo(() => selectedNodes.length === 1 ? selectedNodes[0] : null, [selectedNodes]);
|
|
|
|
const isRunOptionsTutorialStep = useMemo(() => {
|
|
if (!tutorialActive || tutorialSteps.length === 0) return false;
|
|
const currentStep = tutorialSteps[currentTutorialStep];
|
|
return currentStep?.id === "explain-run-options";
|
|
}, [currentTutorialStep, tutorialActive, tutorialSteps]);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (runMenuRef.current && !runMenuRef.current.contains(event.target as Node) && !isRunOptionsTutorialStep) {
|
|
setRunMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
if (runMenuOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, [isRunOptionsTutorialStep, runMenuOpen]);
|
|
|
|
useEffect(() => {
|
|
if (isRunOptionsTutorialStep) {
|
|
setRunMenuOpen(true);
|
|
}
|
|
}, [isRunOptionsTutorialStep]);
|
|
|
|
useEffect(() => {
|
|
if (!tutorialActive || tutorialSteps.length === 0) return;
|
|
const currentStep = tutorialSteps[currentTutorialStep];
|
|
if (
|
|
currentStep?.id === "run-workflow" ||
|
|
currentStep?.id === "demonstrate-downstream" ||
|
|
currentStep?.id === "demonstrate-complete"
|
|
) {
|
|
setRunMenuOpen(false);
|
|
}
|
|
}, [currentTutorialStep, tutorialActive, tutorialSteps]);
|
|
|
|
const runningNodeCount = currentNodeIds.length;
|
|
const getRunningLabel = useCallback(() => {
|
|
if (runningNodeCount === 0) return t("toolbar.running");
|
|
if (runningNodeCount === 1) {
|
|
const node = nodes.find((item) => item.id === currentNodeIds[0]);
|
|
const customTitle = node?.data?.customTitle;
|
|
const nodeName = typeof customTitle === "string" || typeof customTitle === "number" ? customTitle : node?.type || "node";
|
|
return t("toolbar.runningNode", { node: nodeName });
|
|
}
|
|
return t("toolbar.runningNodes", { count: runningNodeCount });
|
|
}, [currentNodeIds, nodes, runningNodeCount, t]);
|
|
|
|
const toggleEdgeStyle = () => {
|
|
setEdgeStyle(edgeStyle === "angular" ? "curved" : "angular");
|
|
};
|
|
|
|
const handleRunClick = useCallback(() => {
|
|
const ftuxState = useFTUXStore.getState();
|
|
const currentStep = ftuxState.tutorialSteps[ftuxState.currentTutorialStep];
|
|
|
|
if (isRunning) {
|
|
stopWorkflow();
|
|
} else if (ftuxState.tutorialActive && currentStep?.id === "run-workflow") {
|
|
void mockTutorialExecution();
|
|
} else {
|
|
void executeWorkflow();
|
|
}
|
|
}, [executeWorkflow, isRunning, mockTutorialExecution, stopWorkflow]);
|
|
|
|
const handleRunFromSelected = useCallback(() => {
|
|
if (!selectedNode) return;
|
|
void executeWorkflow(selectedNode.id);
|
|
setRunMenuOpen(false);
|
|
}, [executeWorkflow, selectedNode]);
|
|
|
|
const handleRunSelectedOnly = useCallback(() => {
|
|
if (!selectedNode) return;
|
|
void regenerateNode(selectedNode.id);
|
|
setRunMenuOpen(false);
|
|
}, [regenerateNode, selectedNode]);
|
|
|
|
const handleRunSelectedNodes = useCallback(() => {
|
|
if (selectedNodes.length === 0) return;
|
|
void executeSelectedNodes(selectedNodes.map((node) => node.id));
|
|
setRunMenuOpen(false);
|
|
}, [executeSelectedNodes, selectedNodes]);
|
|
|
|
return (
|
|
<div className="fixed left-5 top-1/2 z-50 -translate-y-1/2">
|
|
<div className="flex flex-col items-center gap-2 rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-2)] px-1.5 py-1.5 shadow-[var(--shadow-panel)]">
|
|
<GenerateComboButton />
|
|
<CanvasOutlineButton />
|
|
<AssetLibraryButton />
|
|
<MyCreationButton />
|
|
|
|
<button
|
|
onClick={toggleEdgeStyle}
|
|
title={t("toolbar.switchConnectors", { style: edgeStyle === "angular" ? t("toolbar.curved") : t("toolbar.angular") })}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
{edgeStyle === "angular" ? (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 12h4l4-8 4 8h4" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 12c0 0 4-8 8-8s8 8 8 8" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|