mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-19 05:46:04 +00:00
public commit
This commit is contained in:
203
app/src/ui/components/canvas/Canvas.tsx
Normal file
203
app/src/ui/components/canvas/Canvas.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
type MiniMapProps,
|
||||
ReactFlow,
|
||||
type ReactFlowProps,
|
||||
ReactFlowProvider,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow
|
||||
} from "@xyflow/react";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
externalProvider?: boolean;
|
||||
backgroundStyle?: "lines" | "dots";
|
||||
minimap?: boolean | MiniMapProps;
|
||||
children?: JSX.Element | ReactNode;
|
||||
onDropNewNode?: (base: any) => any;
|
||||
onDropNewEdge?: (base: any) => any;
|
||||
};
|
||||
|
||||
export function Canvas({
|
||||
nodes: _nodes,
|
||||
edges: _edges,
|
||||
externalProvider,
|
||||
backgroundStyle = "lines",
|
||||
minimap = false,
|
||||
children,
|
||||
onDropNewNode,
|
||||
onDropNewEdge,
|
||||
...props
|
||||
}: CanvasProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { theme } = useBkndSystemTheme();
|
||||
|
||||
const [isCommandPressed, setIsCommandPressed] = useState(false);
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
const [isPointerPressed, setIsPointerPressed] = useState(false);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.metaKey) {
|
||||
setIsCommandPressed(true);
|
||||
}
|
||||
if (event.key === " ") {
|
||||
//event.preventDefault(); // Prevent default space scrolling behavior
|
||||
setIsSpacePressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (!event.metaKey) {
|
||||
setIsCommandPressed(false);
|
||||
}
|
||||
if (event.key === " ") {
|
||||
setIsSpacePressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
if (isSpacePressed) {
|
||||
setIsPointerPressed(false);
|
||||
return;
|
||||
}
|
||||
setIsPointerPressed(true);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (isSpacePressed) {
|
||||
setIsPointerPressed(false);
|
||||
return;
|
||||
}
|
||||
setIsPointerPressed(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelector("html")?.classList.add("fixed");
|
||||
|
||||
// Add global key listeners
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
// Add global pointer listeners
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
|
||||
// Cleanup event listeners on component unmount
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
document.querySelector("html")?.classList.remove("fixed");
|
||||
};
|
||||
}, []);
|
||||
|
||||
//console.log("mode", { cmd: isCommandPressed, space: isSpacePressed, mouse: isPointerPressed });
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(_nodes ?? []);
|
||||
setEdges(_edges ?? []);
|
||||
}, [_nodes, _edges]);
|
||||
|
||||
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []);
|
||||
|
||||
const onConnectEnd = useCallback(
|
||||
(event, connectionState) => {
|
||||
if (!onDropNewNode || !onDropNewEdge) return;
|
||||
|
||||
const { fromNode, fromHandle, fromPosition } = connectionState;
|
||||
// when a connection is dropped on the pane it's not valid
|
||||
if (!connectionState.isValid) {
|
||||
console.log("conn", { event, connectionState });
|
||||
// we need to remove the wrapper bounds, in order to get the correct position
|
||||
|
||||
const { clientX, clientY } =
|
||||
"changedTouches" in event ? event.changedTouches[0] : event;
|
||||
const newNode = onDropNewNode({
|
||||
id: "select",
|
||||
type: "default",
|
||||
data: { label: "" },
|
||||
position: screenToFlowPosition({
|
||||
x: clientX,
|
||||
y: clientY
|
||||
}),
|
||||
origin: [0.0, 0.0]
|
||||
});
|
||||
|
||||
setNodes((nds) => nds.concat(newNode as any));
|
||||
setEdges((eds) =>
|
||||
eds.concat(
|
||||
onDropNewNode({
|
||||
id: newNode.id,
|
||||
source: connectionState.fromNode.id,
|
||||
target: newNode.id
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[screenToFlowPosition]
|
||||
);
|
||||
//console.log("edges1", edges);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
colorMode={theme}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={onConnectEnd}
|
||||
className={
|
||||
isCommandPressed
|
||||
? "cursor-zoom-in"
|
||||
: isSpacePressed
|
||||
? isPointerPressed
|
||||
? "cursor-grabbing"
|
||||
: "cursor-grab"
|
||||
: ""
|
||||
}
|
||||
proOptions={{
|
||||
hideAttribution: true
|
||||
}}
|
||||
fitView
|
||||
fitViewOptions={{
|
||||
maxZoom: 1.5,
|
||||
...props.fitViewOptions
|
||||
}}
|
||||
nodeDragThreshold={25}
|
||||
panOnScrollSpeed={1}
|
||||
snapToGrid
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodesConnectable={false}
|
||||
/*panOnDrag={isSpacePressed}*/
|
||||
panOnDrag={true}
|
||||
zoomOnScroll={isCommandPressed}
|
||||
panOnScroll={!isCommandPressed}
|
||||
zoomOnDoubleClick={false}
|
||||
selectionOnDrag={!isSpacePressed}
|
||||
{...props}
|
||||
>
|
||||
{backgroundStyle === "lines" && (
|
||||
<Background
|
||||
color={theme === "light" ? "rgba(0,0,0,.1)" : "rgba(255,255,255,.1)"}
|
||||
gap={[50, 50]}
|
||||
variant={BackgroundVariant.Lines}
|
||||
/>
|
||||
)}
|
||||
{backgroundStyle === "dots" && (
|
||||
<Background color={theme === "light" ? "rgba(0,0,0,.5)" : "rgba(255,255,255,.2)"} />
|
||||
)}
|
||||
{minimap && <MiniMap {...(typeof minimap === "object" ? minimap : {})} />}
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ElementProps } from "@mantine/core";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
type TDefaultNodeProps = ElementProps<"div"> & {
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export function DefaultNode({ selected, children, className, ...props }: TDefaultNodeProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative w-80 shadow-lg rounded-lg bg-background",
|
||||
selected && "outline outline-blue-500/25",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TDefaultNodeHeaderProps = ElementProps<"div"> & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const Header: React.FC<TDefaultNodeHeaderProps> = ({ className, label, children, ...props }) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row bg-primary/15 justify-center items-center rounded-tl-lg rounded-tr-lg py-1 px-2 drag-handle",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span className="font-semibold opacity-75 font-mono">{label ?? "Untitled node"}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Content: React.FC<ElementProps<"div">> = ({ children, className, ...props }) => (
|
||||
<div {...props} className={twMerge("px-2 py-1.5 pb-2 flex flex-col", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
DefaultNode.Header = Header;
|
||||
DefaultNode.Content = Content;
|
||||
55
app/src/ui/components/canvas/layouts/index.ts
Normal file
55
app/src/ui/components/canvas/layouts/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
|
||||
type Position = "top" | "right" | "bottom" | "left";
|
||||
type Node = {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
type Edge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
export type LayoutProps = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
graph?: Dagre.GraphLabel;
|
||||
};
|
||||
|
||||
export const layoutWithDagre = ({ nodes, edges, graph }: LayoutProps) => {
|
||||
const dagreGraph = new Dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
dagreGraph.setGraph(graph || {});
|
||||
/*dagreGraph.setGraph({
|
||||
rankdir: "LR",
|
||||
align: "UR",
|
||||
nodesep: NODE_SEP,
|
||||
ranksep: RANK_SEP
|
||||
});*/
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, {
|
||||
width: node.width,
|
||||
height: node.height
|
||||
});
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.target, edge.source);
|
||||
});
|
||||
|
||||
Dagre.layout(dagreGraph);
|
||||
|
||||
return {
|
||||
nodes: nodes.map((node) => {
|
||||
const position = dagreGraph.node(node.id);
|
||||
return { ...node, x: position.x, y: position.y };
|
||||
}),
|
||||
edges
|
||||
};
|
||||
};
|
||||
78
app/src/ui/components/canvas/panels/Panel.tsx
Normal file
78
app/src/ui/components/canvas/panels/Panel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type PanelPosition, Panel as XYPanel } from "@xyflow/react";
|
||||
import { type ComponentPropsWithoutRef, type HTMLAttributes, forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton as _IconButton } from "ui/components/buttons/IconButton";
|
||||
|
||||
export type PanelProps = HTMLAttributes<HTMLDivElement> & {
|
||||
position: PanelPosition;
|
||||
unstyled?: boolean;
|
||||
};
|
||||
|
||||
export function Panel({ position, className, children, unstyled, ...props }: PanelProps) {
|
||||
if (unstyled) {
|
||||
return (
|
||||
<XYPanel
|
||||
position={position}
|
||||
className={twMerge("flex flex-row p-1 gap-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</XYPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<XYPanel position={position} {...props}>
|
||||
<Wrapper className={className}>{children}</Wrapper>
|
||||
</XYPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = ({ children, className, ...props }: ComponentPropsWithoutRef<"div">) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row bg-lightest border ring-2 ring-muted/5 border-muted rounded-full items-center p-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const IconButton = ({
|
||||
Icon,
|
||||
size = "lg",
|
||||
variant = "ghost",
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
round,
|
||||
...rest
|
||||
}: ComponentPropsWithoutRef<typeof _IconButton> & { round?: boolean }) => (
|
||||
<_IconButton
|
||||
Icon={Icon}
|
||||
size={size}
|
||||
variant={variant}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={twMerge(round ? "rounded-full" : "", className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
const Text = forwardRef<any, ComponentPropsWithoutRef<"span"> & { mono?: boolean }>(
|
||||
({ children, className, mono, ...props }, ref) => (
|
||||
<span
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge("text-md font-medium leading-none", mono && "font-mono", className)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
Panel.Wrapper = Wrapper;
|
||||
Panel.IconButton = IconButton;
|
||||
Panel.Text = Text;
|
||||
65
app/src/ui/components/canvas/panels/index.tsx
Normal file
65
app/src/ui/components/canvas/panels/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { MiniMap, useReactFlow, useViewport } from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { TbMaximize, TbMinus, TbPlus, TbSitemap } from "react-icons/tb";
|
||||
import { Panel } from "ui/components/canvas/panels/Panel";
|
||||
|
||||
export type PanelsProps = {
|
||||
children?: React.ReactNode;
|
||||
coordinates?: boolean;
|
||||
minimap?: boolean;
|
||||
zoom?: boolean;
|
||||
};
|
||||
|
||||
export function Panels({ children, ...props }: PanelsProps) {
|
||||
const [minimap, setMinimap] = useState(false);
|
||||
const reactFlow = useReactFlow();
|
||||
const { zoom, x, y } = useViewport();
|
||||
const percent = Math.round(zoom * 100);
|
||||
|
||||
const handleZoomIn = async () => await reactFlow.zoomIn();
|
||||
const handleZoomReset = async () => reactFlow.zoomTo(1);
|
||||
const handleZoomOut = async () => await reactFlow.zoomOut();
|
||||
function toggleMinimap() {
|
||||
setMinimap((p) => !p);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{props.coordinates && (
|
||||
<Panel position="bottom-center">
|
||||
<Panel.Text className="px-2" mono>
|
||||
{x.toFixed(2)},{y.toFixed(2)}
|
||||
</Panel.Text>
|
||||
</Panel>
|
||||
)}
|
||||
<Panel unstyled position="bottom-right">
|
||||
{props.zoom && (
|
||||
<>
|
||||
<Panel.Wrapper className="px-1.5">
|
||||
<Panel.IconButton Icon={TbPlus} round onClick={handleZoomIn} />
|
||||
<Panel.Text className="px-2" mono onClick={handleZoomReset}>
|
||||
{percent}%
|
||||
</Panel.Text>
|
||||
<Panel.IconButton Icon={TbMinus} round onClick={handleZoomOut} />
|
||||
<Panel.IconButton Icon={TbMaximize} round onClick={handleZoomReset} />
|
||||
</Panel.Wrapper>
|
||||
</>
|
||||
)}
|
||||
{props.minimap && (
|
||||
<>
|
||||
<Panel.Wrapper>
|
||||
<Panel.IconButton
|
||||
Icon={minimap ? TbSitemap : TbSitemap}
|
||||
round
|
||||
onClick={toggleMinimap}
|
||||
variant={minimap ? "default" : "ghost"}
|
||||
/>
|
||||
</Panel.Wrapper>
|
||||
{minimap && <MiniMap style={{ bottom: 50, right: -5 }} ariaLabel={null} />}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user