public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

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

View File

@@ -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;

View 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
};
};

View 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;

View 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>
</>
);
}