mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 17:12:44 +08:00
feat: Introduce CanvasControls and dropdown components for enhanced canvas interaction
- Added CanvasControls component to manage canvas interactions and state based on flow lock status. - Implemented CanvasControlsDropdown and HelpDropdown for zoom and help functionalities, respectively. - Created DropdownControlButton for reusable dropdown actions with keyboard shortcuts. - Removed deprecated dropdowns and refactored related tests to align with new component structure. - Introduced utility functions for zoom percentage formatting and modifier key detection.
This commit is contained in:
@ -1,6 +1,3 @@
|
||||
import { memo, useMemo, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import IconComponent from "@/components/common/genericIconComponent";
|
||||
import ShadTooltip from "@/components/common/shadTooltipComponent";
|
||||
import FlowSettingsComponent from "@/components/core/flowSettingsComponent";
|
||||
@ -23,8 +20,12 @@ import useFlowsManagerStore from "@/stores/flowsManagerStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcuts";
|
||||
import { swatchColors } from "@/utils/styleUtils";
|
||||
import { cn, getNumberFromString } from "@/utils/utils";
|
||||
import { memo, useMemo, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const MenuBar = memo((): JSX.Element => {
|
||||
const currentFlow = useFlowStore((state) => state.currentFlow);
|
||||
const setSuccessData = useAlertStore((state) => state.setSuccessData);
|
||||
const saveLoading = useFlowsManagerStore((state) => state.saveLoading);
|
||||
const [openSettings, setOpenSettings] = useState(false);
|
||||
@ -140,6 +141,7 @@ export const MenuBar = memo((): JSX.Element => {
|
||||
>
|
||||
{currentFlowName || "Untitled Flow"}
|
||||
</span>
|
||||
{currentFlow?.locked && <IconComponent name="Lock" className="h-5 w-3.5" />}
|
||||
|
||||
<IconComponent
|
||||
name="pencil"
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import { Panel, useStoreApi } from "@xyflow/react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import CanvasControlsDropdown from "./CanvasControlsDropdown";
|
||||
import HelpDropdown from "./HelpDropdown";
|
||||
|
||||
const CanvasControls = ({ children }: { children?: ReactNode }) => {
|
||||
const reactFlowStoreApi = useStoreApi();
|
||||
const isFlowLocked = useFlowStore(
|
||||
useShallow((state) => state.currentFlow?.locked),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reactFlowStoreApi.setState({
|
||||
nodesDraggable: !isFlowLocked,
|
||||
nodesConnectable: !isFlowLocked,
|
||||
elementsSelectable: !isFlowLocked,
|
||||
});
|
||||
}, [isFlowLocked, reactFlowStoreApi]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
data-testid="main_canvas_controls"
|
||||
className="react-flow__controls !left-auto !m-2 flex !flex-row rounded-md border border-border bg-background fill-foreground stroke-foreground text-primary [&>button]:border-0"
|
||||
position="bottom-right"
|
||||
>
|
||||
{children}
|
||||
{children && (
|
||||
<span>
|
||||
<Separator orientation="vertical" />
|
||||
</span>
|
||||
)}
|
||||
<CanvasControlsDropdown />
|
||||
<span>
|
||||
<Separator orientation="vertical" />
|
||||
</span>
|
||||
<HelpDropdown />
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasControls;
|
||||
@ -0,0 +1,146 @@
|
||||
import { useReactFlow, useStore } from "@xyflow/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import IconComponent from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import DropdownControlButton from "./DropdownControlButton";
|
||||
import { formatZoomPercentage, reactFlowSelector } from "./utils/canvasUtils";
|
||||
|
||||
export const KEYBOARD_SHORTCUTS = {
|
||||
ZOOM_IN: { key: "+", code: "Equal" },
|
||||
ZOOM_OUT: { key: "-", code: "Minus" },
|
||||
FIT_VIEW: { key: "1", code: "Digit1" },
|
||||
RESET_ZOOM: { key: "0", code: "Digit0" },
|
||||
} as const;
|
||||
|
||||
const CanvasControlsDropdown = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { fitView, zoomIn, zoomOut, zoomTo } = useReactFlow();
|
||||
|
||||
const { minZoomReached, maxZoomReached, zoom } = useStore(
|
||||
reactFlowSelector,
|
||||
shallow,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const isModifierPressed = event.metaKey || event.ctrlKey;
|
||||
|
||||
if (!isModifierPressed) return;
|
||||
|
||||
switch (event.code) {
|
||||
case KEYBOARD_SHORTCUTS.ZOOM_IN.code:
|
||||
event.preventDefault();
|
||||
if (!maxZoomReached) {
|
||||
zoomIn();
|
||||
}
|
||||
break;
|
||||
case KEYBOARD_SHORTCUTS.ZOOM_OUT.code:
|
||||
event.preventDefault();
|
||||
if (minZoomReached || zoom <= 0.6) {
|
||||
zoomTo(1);
|
||||
} else {
|
||||
zoomOut();
|
||||
}
|
||||
break;
|
||||
case KEYBOARD_SHORTCUTS.FIT_VIEW.code:
|
||||
event.preventDefault();
|
||||
fitView();
|
||||
break;
|
||||
case KEYBOARD_SHORTCUTS.RESET_ZOOM.code:
|
||||
event.preventDefault();
|
||||
zoomTo(1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [zoomIn, zoomOut, fitView, zoomTo, maxZoomReached, minZoomReached, zoom]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
zoomIn();
|
||||
}, [zoomIn]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
zoomOut();
|
||||
}, [zoomOut]);
|
||||
|
||||
const handleFitView = useCallback(() => {
|
||||
fitView();
|
||||
}, [fitView]);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
zoomTo(1);
|
||||
}, [zoomTo]);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="canvas_controls_dropdown"
|
||||
className="group rounded-none px-2 py-2 hover:bg-muted"
|
||||
unstyled
|
||||
title="Canvas Controls"
|
||||
>
|
||||
<div className="flex items-center justify-center ">
|
||||
<div className="text-sm text-primary pr-1">
|
||||
{formatZoomPercentage(zoom)}
|
||||
</div>
|
||||
<IconComponent
|
||||
name={isOpen ? "ChevronDown" : "ChevronUp"}
|
||||
aria-hidden="true"
|
||||
className="text-primary group-hover:text-primary !h-5 !w-5"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="flex flex-col w-full"
|
||||
>
|
||||
<DropdownControlButton
|
||||
tooltipText="Zoom In"
|
||||
onClick={handleZoomIn}
|
||||
disabled={maxZoomReached}
|
||||
testId="zoom_in"
|
||||
label="Zoom In"
|
||||
shortcut={KEYBOARD_SHORTCUTS.ZOOM_IN.key}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
tooltipText="Zoom Out"
|
||||
onClick={handleZoomOut}
|
||||
disabled={minZoomReached}
|
||||
testId="zoom_out"
|
||||
label="Zoom Out"
|
||||
shortcut={KEYBOARD_SHORTCUTS.ZOOM_OUT.key}
|
||||
/>
|
||||
<Separator />
|
||||
<DropdownControlButton
|
||||
tooltipText="Reset zoom to 100%"
|
||||
onClick={handleResetZoom}
|
||||
testId="reset_zoom"
|
||||
label="Zoom To 100%"
|
||||
shortcut={KEYBOARD_SHORTCUTS.RESET_ZOOM.key}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
tooltipText="Fit view to show all nodes"
|
||||
onClick={handleFitView}
|
||||
testId="fit_view"
|
||||
label="Zoom To Fit"
|
||||
shortcut={KEYBOARD_SHORTCUTS.FIT_VIEW.key}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasControlsDropdown;
|
||||
@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/utils/utils";
|
||||
import ToggleShadComponent from "../parameterRenderComponent/components/toggleShadComponent";
|
||||
import { getModifierKey } from "./utils/canvasUtils";
|
||||
|
||||
export type DropdownControlButtonProps = {
|
||||
tooltipText?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
iconName?: string;
|
||||
hasToogle?: boolean;
|
||||
toggleValue?: boolean;
|
||||
};
|
||||
|
||||
const DropdownControlButton: React.FC<DropdownControlButtonProps> = ({
|
||||
tooltipText,
|
||||
onClick = () => {},
|
||||
disabled,
|
||||
testId,
|
||||
label = "",
|
||||
shortcut = "",
|
||||
iconName,
|
||||
hasToogle = false,
|
||||
toggleValue = false,
|
||||
}) => (
|
||||
<Button
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"group flex items-center justify-center !py-1.5 !px-2 hover:bg-accent h-full rounded-none",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={onClick}
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
title={tooltipText || ""}
|
||||
>
|
||||
{iconName && (
|
||||
<ForwardedIconComponent
|
||||
name={iconName}
|
||||
className="text-muted-foreground group-hover:text-primary"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row items-center justify-between w-full h-full">
|
||||
<span className="text-muted-foreground text-sm mr-2 group-hover:text-primary">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-row items-center justify-center gap-1 text-sm text-placeholder-foreground">
|
||||
{shortcut && (
|
||||
<>
|
||||
<span className="mr-1">{getModifierKey()}</span>
|
||||
<span>{shortcut}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasToogle && (
|
||||
<ToggleShadComponent
|
||||
value={toggleValue}
|
||||
handleOnNewValue={onClick}
|
||||
editNode={true}
|
||||
id="helper_lines"
|
||||
disabled={false}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export default DropdownControlButton;
|
||||
@ -0,0 +1,99 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import IconComponent from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
BUG_REPORT_URL,
|
||||
DATASTAX_DOCS_URL,
|
||||
DESKTOP_URL,
|
||||
DOCS_URL,
|
||||
} from "@/constants/constants";
|
||||
import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags";
|
||||
import DropdownControlButton from "./DropdownControlButton";
|
||||
|
||||
const HelpDropdown = () => {
|
||||
const navigate = useNavigate();
|
||||
const [helperLineEnabled, setHelperLineEnabled] = useState(false);
|
||||
const [isHelpMenuOpen, setIsHelpMenuOpen] = useState(false);
|
||||
|
||||
const onToggleHelperLines = useCallback(() => {
|
||||
setHelperLineEnabled(!helperLineEnabled);
|
||||
}, [helperLineEnabled]);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isHelpMenuOpen} onOpenChange={setIsHelpMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group flex items-center justify-center px-2 rounded-none"
|
||||
title="Help"
|
||||
>
|
||||
<IconComponent
|
||||
name="Circle-Help"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary !h-5 !w-5"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="flex flex-col w-full"
|
||||
>
|
||||
<DropdownControlButton
|
||||
iconName="book-open"
|
||||
testId="canvas_controls_dropdown_docs"
|
||||
label="Docs"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
ENABLE_DATASTAX_LANGFLOW ? DATASTAX_DOCS_URL : DOCS_URL,
|
||||
"_blank",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
iconName="keyboard"
|
||||
testId="canvas_controls_dropdown_shortcuts"
|
||||
label="Shortcuts"
|
||||
onClick={() => {
|
||||
navigate("/settings/shortcuts");
|
||||
}}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
iconName="bug"
|
||||
testId="canvas_controls_dropdown_report_a_bug"
|
||||
label="Report a bug"
|
||||
onClick={() => {
|
||||
window.open(BUG_REPORT_URL, "_blank");
|
||||
}}
|
||||
/>
|
||||
<Separator />
|
||||
<DropdownControlButton
|
||||
iconName="download"
|
||||
testId="canvas_controls_dropdown_get_langflow_desktop"
|
||||
label="Get Langflow Desktop"
|
||||
onClick={() => {
|
||||
window.open(DESKTOP_URL, "_blank");
|
||||
}}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
iconName={!helperLineEnabled ? "UnfoldHorizontal" : "FoldHorizontal"}
|
||||
testId="canvas_controls_dropdown_enable_smart_guides"
|
||||
onClick={onToggleHelperLines}
|
||||
toggleValue={helperLineEnabled}
|
||||
label="Enable smart guides"
|
||||
hasToogle={true}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpDropdown;
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import CanvasControls from "../index";
|
||||
import CanvasControls from "../CanvasControls";
|
||||
|
||||
// Capture flow functions for assertions
|
||||
const reactFlowFns = {
|
||||
@ -38,11 +38,14 @@ jest.mock("@/components/ui/separator", () => ({
|
||||
}));
|
||||
|
||||
// Mock dropdowns to a simple render that exposes props for assertions
|
||||
jest.mock("../dropdowns", () => ({
|
||||
CanvasControlsDropdown: (props: any) => (
|
||||
<div data-testid="controls-dropdown" {...props} />
|
||||
),
|
||||
HelpDropdown: (props: any) => <div data-testid="help-dropdown" {...props} />,
|
||||
jest.mock("../CanvasControlsDropdown", () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <div data-testid="controls-dropdown" {...props} />,
|
||||
}));
|
||||
|
||||
jest.mock("../HelpDropdown", () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <div data-testid="help-dropdown" {...props} />,
|
||||
}));
|
||||
|
||||
describe("CanvasControls", () => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||
import { CanvasControlsDropdown, HelpDropdown } from "../dropdowns";
|
||||
import CanvasControlsDropdown from "../CanvasControlsDropdown";
|
||||
import HelpDropdown from "../HelpDropdown";
|
||||
|
||||
jest.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, ...props }: any) => (
|
||||
|
||||
@ -1,272 +0,0 @@
|
||||
import IconComponent, {
|
||||
ForwardedIconComponent,
|
||||
} from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
BUG_REPORT_URL,
|
||||
DATASTAX_DOCS_URL,
|
||||
DESKTOP_URL,
|
||||
DOCS_URL,
|
||||
} from "@/constants/constants";
|
||||
import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags";
|
||||
import { cn, getOS } from "@/utils/utils";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ToggleShadComponent from "../parameterRenderComponent/components/toggleShadComponent";
|
||||
|
||||
type DropdownControlButtonProps = {
|
||||
tooltipText?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
iconName?: string;
|
||||
hasToogle?: boolean;
|
||||
toggleValue?: boolean;
|
||||
};
|
||||
|
||||
const getModifierKey = (): string => {
|
||||
const os = getOS();
|
||||
return os === "macos" ? "⌘" : "Ctrl";
|
||||
};
|
||||
|
||||
const DropdownControlButton: React.FC<DropdownControlButtonProps> = ({
|
||||
tooltipText,
|
||||
onClick = () => {},
|
||||
disabled,
|
||||
testId,
|
||||
label = "",
|
||||
shortcut = "",
|
||||
iconName,
|
||||
hasToogle=false,
|
||||
toggleValue = false,
|
||||
}) => (
|
||||
<Button
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"group flex items-center justify-center !py-1.5 !px-2 hover:bg-accent h-full rounded-none",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={onClick}
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
title={tooltipText || ""}
|
||||
>
|
||||
{iconName && (
|
||||
<ForwardedIconComponent
|
||||
name={iconName}
|
||||
className="text-muted-foreground group-hover:text-primary"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row items-center justify-between w-full h-full">
|
||||
<span className="text-muted-foreground text-sm mr-2 group-hover:text-primary">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-row items-center justify-center gap-1 text-sm text-placeholder-foreground">
|
||||
{shortcut && (
|
||||
<>
|
||||
<span className="mr-1">{getModifierKey()}</span>
|
||||
<span>{shortcut}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasToogle && (
|
||||
<ToggleShadComponent
|
||||
value={toggleValue}
|
||||
handleOnNewValue={onClick}
|
||||
editNode={true}
|
||||
id="enable_smart_guides"
|
||||
disabled={false}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const formatZoomPercentage = (zoom: number): string =>
|
||||
`${Math.round(zoom * 100)}%`;
|
||||
|
||||
export type CanvasControlsDropdownProps = {
|
||||
zoom: number;
|
||||
minZoomReached: boolean;
|
||||
maxZoomReached: boolean;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetZoom: () => void;
|
||||
onFitView: () => void;
|
||||
shortcuts: {
|
||||
ZOOM_IN: { key: string };
|
||||
ZOOM_OUT: { key: string };
|
||||
RESET_ZOOM: { key: string };
|
||||
FIT_VIEW: { key: string };
|
||||
};
|
||||
};
|
||||
|
||||
export const CanvasControlsDropdown: React.FC<CanvasControlsDropdownProps> = ({
|
||||
zoom,
|
||||
minZoomReached,
|
||||
maxZoomReached,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetZoom,
|
||||
onFitView,
|
||||
shortcuts,
|
||||
}) => (
|
||||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="canvas_controls_dropdown"
|
||||
className="group rounded-none px-2 py-2 hover:bg-muted"
|
||||
unstyled
|
||||
title="Canvas Controls"
|
||||
>
|
||||
<div className="flex items-center justify-center ">
|
||||
<div className="text-sm text-primary pr-1">
|
||||
{formatZoomPercentage(zoom)}
|
||||
</div>
|
||||
<IconComponent
|
||||
name={isOpen ? "ChevronDown" : "ChevronUp"}
|
||||
aria-hidden="true"
|
||||
className="text-primary group-hover:text-primary !h-5 !w-5"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="flex flex-col w-full"
|
||||
>
|
||||
<DropdownControlButton
|
||||
tooltipText="Zoom In"
|
||||
onClick={onZoomIn}
|
||||
disabled={maxZoomReached}
|
||||
testId="zoom_in_dropdown"
|
||||
label="Zoom In"
|
||||
shortcut={shortcuts.ZOOM_IN.key}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
tooltipText="Zoom Out"
|
||||
onClick={onZoomOut}
|
||||
disabled={minZoomReached}
|
||||
testId="zoom_out_dropdown"
|
||||
label="Zoom Out"
|
||||
shortcut={shortcuts.ZOOM_OUT.key}
|
||||
/>
|
||||
<Separator />
|
||||
<DropdownControlButton
|
||||
tooltipText="Reset zoom to 100%"
|
||||
onClick={onResetZoom}
|
||||
testId="reset_zoom_dropdown"
|
||||
label="Zoom To 100%"
|
||||
shortcut={shortcuts.RESET_ZOOM.key}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
tooltipText="Fit view to show all nodes"
|
||||
onClick={onFitView}
|
||||
testId="fit_view_dropdown"
|
||||
label="Zoom To Fit"
|
||||
shortcut={shortcuts.FIT_VIEW.key}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
export type HelpDropdownProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelectAction?: (action: "mock1" | "mock2" | "mock3") => void;
|
||||
};
|
||||
|
||||
export const HelpDropdown: React.FC<HelpDropdownProps> = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onSelectAction,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [helperLineEnabled, setHelperLineEnabled] = useState(false);
|
||||
const onToggleHelperLines = useCallback(() => {
|
||||
setHelperLineEnabled(!helperLineEnabled);
|
||||
}, [helperLineEnabled]);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group flex items-center justify-center px-2 rounded-none"
|
||||
title="Help"
|
||||
>
|
||||
<IconComponent
|
||||
name="Circle-Help"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary !h-5 !w-5"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="flex flex-col w-full"
|
||||
>
|
||||
<DropdownControlButton
|
||||
iconName="book-open"
|
||||
testId="canvas_controls_dropdown_docs"
|
||||
label="Docs"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
ENABLE_DATASTAX_LANGFLOW ? DATASTAX_DOCS_URL : DOCS_URL,
|
||||
"_blank",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
iconName="keyboard"
|
||||
testId="canvas_controls_dropdown_shortcuts"
|
||||
label="Shortcuts"
|
||||
onClick={() => {
|
||||
navigate("/settings/shortcuts");
|
||||
}}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
iconName="bug"
|
||||
testId="canvas_controls_dropdown_report_a_bug"
|
||||
label="Report a bug"
|
||||
onClick={() => {
|
||||
window.open(BUG_REPORT_URL, "_blank");
|
||||
}}
|
||||
/>
|
||||
<Separator />
|
||||
<DropdownControlButton
|
||||
iconName="download"
|
||||
testId="canvas_controls_dropdown_get_langflow_desktop"
|
||||
label="Get Langflow Desktop"
|
||||
onClick={() => {
|
||||
window.open(DESKTOP_URL, "_blank");
|
||||
}}
|
||||
/>
|
||||
<DropdownControlButton
|
||||
iconName={!helperLineEnabled ? "UnfoldHorizontal" : "FoldHorizontal"}
|
||||
testId="canvas_controls_dropdown_enable_smart_guides"
|
||||
onClick={onToggleHelperLines}
|
||||
toggleValue={helperLineEnabled}
|
||||
label="Enable smart guides"
|
||||
hasToogle={true}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -1,143 +0,0 @@
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import {
|
||||
Panel,
|
||||
type ReactFlowState,
|
||||
useReactFlow,
|
||||
useStore,
|
||||
useStoreApi,
|
||||
} from "@xyflow/react";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { CanvasControlsDropdown, HelpDropdown } from "./dropdowns";
|
||||
|
||||
const KEYBOARD_SHORTCUTS = {
|
||||
ZOOM_IN: { key: "+", code: "Equal" },
|
||||
ZOOM_OUT: { key: "-", code: "Minus" },
|
||||
FIT_VIEW: { key: "1", code: "Digit1" },
|
||||
RESET_ZOOM: { key: "0", code: "Digit0" },
|
||||
} as const;
|
||||
|
||||
const reactFlowSelector = (s: ReactFlowState) => ({
|
||||
isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable,
|
||||
minZoomReached: s.transform[2] <= s.minZoom,
|
||||
maxZoomReached: s.transform[2] >= s.maxZoom,
|
||||
zoom: s.transform[2],
|
||||
});
|
||||
|
||||
const CanvasControls = ({ children }: { children?: ReactNode }) => {
|
||||
const reactFlowStoreApi = useStoreApi();
|
||||
const { fitView, zoomIn, zoomOut, zoomTo } = useReactFlow();
|
||||
const { minZoomReached, maxZoomReached, zoom } = useStore(
|
||||
reactFlowSelector,
|
||||
shallow,
|
||||
);
|
||||
const [isControlsMenuOpen, setIsControlsMenuOpen] = useState(false);
|
||||
const [isHelpMenuOpen, setIsHelpMenuOpen] = useState(false);
|
||||
const isFlowLocked = useFlowStore(
|
||||
useShallow((state) => state.currentFlow?.locked),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const isModifierPressed = event.metaKey || event.ctrlKey;
|
||||
|
||||
if (!isModifierPressed) return;
|
||||
|
||||
switch (event.code) {
|
||||
case KEYBOARD_SHORTCUTS.ZOOM_IN.code:
|
||||
event.preventDefault();
|
||||
if (!maxZoomReached) {
|
||||
zoomIn();
|
||||
}
|
||||
break;
|
||||
case KEYBOARD_SHORTCUTS.ZOOM_OUT.code:
|
||||
event.preventDefault();
|
||||
if (minZoomReached || zoom <= 0.6) {
|
||||
zoomTo(1);
|
||||
} else {
|
||||
zoomOut();
|
||||
}
|
||||
break;
|
||||
case KEYBOARD_SHORTCUTS.FIT_VIEW.code:
|
||||
event.preventDefault();
|
||||
fitView();
|
||||
break;
|
||||
case KEYBOARD_SHORTCUTS.RESET_ZOOM.code:
|
||||
event.preventDefault();
|
||||
zoomTo(1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [zoomIn, zoomOut, fitView, zoomTo, maxZoomReached, minZoomReached]);
|
||||
|
||||
useEffect(() => {
|
||||
reactFlowStoreApi.setState({
|
||||
nodesDraggable: !isFlowLocked,
|
||||
nodesConnectable: !isFlowLocked,
|
||||
elementsSelectable: !isFlowLocked,
|
||||
});
|
||||
}, [isFlowLocked, reactFlowStoreApi]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
zoomIn();
|
||||
setIsControlsMenuOpen(false);
|
||||
}, [zoomIn]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
zoomOut();
|
||||
setIsControlsMenuOpen(false);
|
||||
}, [zoomOut]);
|
||||
|
||||
const handleFitView = useCallback(() => {
|
||||
fitView();
|
||||
setIsControlsMenuOpen(false);
|
||||
}, [fitView]);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
zoomTo(1);
|
||||
setIsControlsMenuOpen(false);
|
||||
}, [zoomTo]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
data-testid="main_canvas_controls"
|
||||
className="react-flow__controls !left-auto !m-2 flex !flex-row rounded-md border border-border bg-background fill-foreground stroke-foreground text-primary [&>button]:border-0"
|
||||
position="bottom-right"
|
||||
>
|
||||
{children}
|
||||
{children && (
|
||||
<span>
|
||||
<Separator orientation="vertical" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<CanvasControlsDropdown
|
||||
zoom={zoom}
|
||||
minZoomReached={minZoomReached}
|
||||
maxZoomReached={maxZoomReached}
|
||||
isOpen={isControlsMenuOpen}
|
||||
onOpenChange={setIsControlsMenuOpen}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onResetZoom={handleResetZoom}
|
||||
onFitView={handleFitView}
|
||||
shortcuts={KEYBOARD_SHORTCUTS}
|
||||
/>
|
||||
<span>
|
||||
<Separator orientation="vertical" />
|
||||
</span>
|
||||
<HelpDropdown
|
||||
isOpen={isHelpMenuOpen}
|
||||
onOpenChange={setIsHelpMenuOpen}
|
||||
onSelectAction={() => setIsHelpMenuOpen(false)}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasControls;
|
||||
@ -0,0 +1,17 @@
|
||||
import { ReactFlowState } from "@xyflow/react";
|
||||
import { getOS } from "@/utils/utils";
|
||||
|
||||
export const getModifierKey = (): string => {
|
||||
const os = getOS();
|
||||
return os === "macos" ? "⌘" : "Ctrl";
|
||||
};
|
||||
|
||||
export const formatZoomPercentage = (zoom: number): string =>
|
||||
`${Math.round(zoom * 100)}%`;
|
||||
|
||||
export const reactFlowSelector = (s: ReactFlowState) => ({
|
||||
isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable,
|
||||
minZoomReached: s.transform[2] <= s.minZoom,
|
||||
maxZoomReached: s.transform[2] >= s.maxZoom,
|
||||
zoom: s.transform[2],
|
||||
});
|
||||
@ -4,7 +4,7 @@ import {
|
||||
default as ForwardedIconComponent,
|
||||
default as IconComponent,
|
||||
} from "@/components/common/genericIconComponent";
|
||||
import CanvasControls from "@/components/core/canvasControlsComponent";
|
||||
import CanvasControls from "@/components/core/canvasControlsComponent/CanvasControls";
|
||||
import LogCanvasControls from "@/components/core/logCanvasControlsComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
Reference in New Issue
Block a user