import { getYjsDoc, syncedStore } from "@syncedstore/core"; import { type Connection, Controls, type Edge, MarkerType, MiniMap, type Node, ReactFlow, ReactFlowProvider, type XYPosition, applyEdgeChanges, applyNodeChanges, useReactFlow, useUpdateNodeInternals, } from "@xyflow/react"; import axios from "axios"; import { type MouseEvent, useCallback, useEffect, useMemo, useState, } from "react"; // The LynxKite workspace editor. import { useParams } from "react-router"; import useSWR, { type Fetcher } from "swr"; import { WebsocketProvider } from "y-websocket"; // @ts-ignore import Atom from "~icons/tabler/atom.jsx"; // @ts-ignore import Backspace from "~icons/tabler/backspace.jsx"; // @ts-ignore import Close from "~icons/tabler/x.jsx"; import type { Workspace, WorkspaceNode } from "../apiTypes.ts"; import favicon from "../assets/favicon.ico"; // import NodeWithTableView from './NodeWithTableView'; import EnvironmentSelector from "./EnvironmentSelector"; import { LynxKiteState } from "./LynxKiteState"; import NodeSearch, { type OpsOp, type Catalog, type Catalogs, } from "./NodeSearch.tsx"; import NodeWithGraphCreationView from "./nodes/GraphCreationNode.tsx"; import NodeWithImage from "./nodes/NodeWithImage.tsx"; import NodeWithParams from "./nodes/NodeWithParams"; import NodeWithTableView from "./nodes/NodeWithTableView.tsx"; import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx"; export default function (props: any) { return ( ); } function LynxKiteFlow() { const updateNodeInternals = useUpdateNodeInternals(); const reactFlow = useReactFlow(); const [nodes, setNodes] = useState([] as Node[]); const [edges, setEdges] = useState([] as Edge[]); const { path } = useParams(); const [state, setState] = useState({ workspace: {} as Workspace }); const [message, setMessage] = useState(null as string | null); useEffect(() => { const state = syncedStore({ workspace: {} as Workspace }); setState(state); const doc = getYjsDoc(state); const proto = location.protocol === "https:" ? "wss:" : "ws:"; const wsProvider = new WebsocketProvider( `${proto}//${location.host}/ws/crdt`, path!, doc, ); const onChange = (_update: any, origin: any, _doc: any, _tr: any) => { if (origin === wsProvider) { // An update from the CRDT. Apply it to the local state. // This is only necessary because ReactFlow keeps secret internal copies of our stuff. if (!state.workspace) return; if (!state.workspace.nodes) return; if (!state.workspace.edges) return; for (const n of state.workspace.nodes) { if (n.dragHandle !== ".bg-primary") { n.dragHandle = ".bg-primary"; } } setNodes([...state.workspace.nodes] as Node[]); setEdges([...state.workspace.edges] as Edge[]); for (const node of state.workspace.nodes) { // Make sure the internal copies are updated. updateNodeInternals(node.id); } } }; doc.on("update", onChange); return () => { doc.destroy(); wsProvider.destroy(); }; }, [path, updateNodeInternals]); const onNodesChange = useCallback( (changes: any[]) => { // An update from the UI. Apply it to the local state... setNodes((nds) => applyNodeChanges(changes, nds)); // ...and to the CRDT state. (Which could be the same, except for ReactFlow's internal copies.) const wnodes = state.workspace?.nodes; if (!wnodes) return; for (const ch of changes) { const nodeIndex = wnodes.findIndex((n) => n.id === ch.id); if (nodeIndex === -1) continue; const node = wnodes[nodeIndex]; if (!node) continue; // Position events sometimes come with NaN values. Ignore them. if ( ch.type === "position" && !Number.isNaN(ch.position.x) && !Number.isNaN(ch.position.y) ) { getYjsDoc(state).transact(() => { Object.assign(node.position, ch.position); }); } else if (ch.type === "select") { } else if (ch.type === "dimensions") { getYjsDoc(state).transact(() => Object.assign(node, ch.dimensions)); } else if (ch.type === "remove") { wnodes.splice(nodeIndex, 1); } else if (ch.type === "replace") { // Ideally we would only update the parameter that changed. But ReactFlow does not give us that detail. const u = { collapsed: ch.item.data.collapsed, // The "..." expansion on a Y.map returns an empty object. Copying with fromEntries/entries instead. params: { ...Object.fromEntries(Object.entries(ch.item.data.params)), }, __execution_delay: ch.item.data.__execution_delay, }; getYjsDoc(state).transact(() => Object.assign(node.data, u)); } else { console.log("Unknown node change", ch); } } }, [state], ); const onEdgesChange = useCallback( (changes: any[]) => { setEdges((eds) => applyEdgeChanges(changes, eds)); const wedges = state.workspace?.edges; if (!wedges) return; for (const ch of changes) { const edgeIndex = wedges.findIndex((e) => e.id === ch.id); if (ch.type === "remove") { wedges.splice(edgeIndex, 1); } else if (ch.type === "select") { } else { console.log("Unknown edge change", ch); } } }, [state], ); const fetcher: Fetcher = (resource: string, init?: RequestInit) => fetch(resource, init).then((res) => res.json()); const catalog = useSWR("/api/catalog", fetcher); const [suppressSearchUntil, setSuppressSearchUntil] = useState(0); const [nodeSearchSettings, setNodeSearchSettings] = useState( undefined as | { pos: XYPosition; boxes: Catalog; } | undefined, ); const nodeTypes = useMemo( () => ({ basic: NodeWithParams, visualization: NodeWithVisualization, image: NodeWithImage, table_view: NodeWithTableView, graph_creation_view: NodeWithGraphCreationView, }), [], ); // Global keyboard shortcuts. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Show the node search dialog on "/". if ( event.key === "/" && !nodeSearchSettings && !isTypingInFormElement() ) { event.preventDefault(); setNodeSearchSettings({ pos: { x: 100, y: 100 }, boxes: catalog.data![state.workspace.env!], }); } }; // TODO: Switch to keydown once https://github.com/xyflow/xyflow/pull/5055 is merged. document.addEventListener("keyup", handleKeyDown); return () => { document.removeEventListener("keyup", handleKeyDown); }; }, [catalog.data, nodeSearchSettings, state.workspace.env]); function isTypingInFormElement() { const activeElement = document.activeElement; return ( activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || (activeElement as HTMLElement).isContentEditable) ); } const closeNodeSearch = useCallback(() => { setNodeSearchSettings(undefined); setSuppressSearchUntil(Date.now() + 200); }, []); const toggleNodeSearch = useCallback( (event: MouseEvent) => { if (suppressSearchUntil > Date.now()) return; if (nodeSearchSettings) { closeNodeSearch(); return; } event.preventDefault(); setNodeSearchSettings({ pos: { x: event.clientX, y: event.clientY }, boxes: catalog.data![state.workspace.env!], }); }, [catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch], ); function addNode( node: Partial, state: { workspace: Workspace }, nodes: Node[], ) { const title = node.data?.title; let i = 1; node.id = `${title} ${i}`; const wnodes = state.workspace.nodes!; while (wnodes.find((x) => x.id === node.id)) { i += 1; node.id = `${title} ${i}`; } wnodes.push(node as WorkspaceNode); setNodes([...nodes, node as WorkspaceNode]); } function nodeFromMeta(meta: OpsOp): Partial { const node: Partial = { type: meta.type, data: { meta: meta, title: meta.name, params: Object.fromEntries( Object.values(meta.params).map((p) => [p.name, p.default]), ), }, }; return node; } const addNodeFromSearch = useCallback( (meta: OpsOp) => { const node = nodeFromMeta(meta); const nss = nodeSearchSettings!; node.position = reactFlow.screenToFlowPosition({ x: nss.pos.x, y: nss.pos.y, }); addNode(node, state, nodes); closeNodeSearch(); }, [nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch], ); const onConnect = useCallback( (connection: Connection) => { setSuppressSearchUntil(Date.now() + 200); const edge = { id: `${connection.source} ${connection.target}`, source: connection.source, sourceHandle: connection.sourceHandle!, target: connection.target, targetHandle: connection.targetHandle!, }; state.workspace.edges!.push(edge); setEdges((oldEdges) => [...oldEdges, edge]); }, [state], ); const parentDir = path!.split("/").slice(0, -1).join("/"); function onDragOver(e: React.DragEvent) { e.stopPropagation(); e.preventDefault(); } async function onDrop(e: React.DragEvent) { e.stopPropagation(); e.preventDefault(); const file = e.dataTransfer.files[0]; const formData = new FormData(); formData.append("file", file); try { await axios.post("/api/upload", formData, { onUploadProgress: (progressEvent) => { const percentCompleted = Math.round( (100 * progressEvent.loaded) / progressEvent.total!, ); if (percentCompleted === 100) setMessage("Processing file..."); else setMessage(`Uploading ${percentCompleted}%`); }, }); setMessage(null); const cat = catalog.data![state.workspace.env!]; const node = nodeFromMeta(cat["Import file"]); node.position = reactFlow.screenToFlowPosition({ x: e.clientX, y: e.clientY, }); node.data!.params.file_path = `uploads/${file.name}`; if (file.name.includes(".csv")) { node.data!.params.file_format = "csv"; } else if (file.name.includes(".parquet")) { node.data!.params.file_format = "parquet"; } else if (file.name.includes(".json")) { node.data!.params.file_format = "json"; } else if (file.name.includes(".xls")) { node.data!.params.file_format = "excel"; } addNode(node, state, nodes); } catch (error) { setMessage("File upload failed."); } } return (
{path}
{ state.workspace.env = env; }} />
{nodeSearchSettings && ( )} {message && (
setMessage(null)}> {message}
)}
); }