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

"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>
);
}