Spaces:
Running
Running
| // The LynxKite workspace editor. | |
| import { useParams } from "react-router"; | |
| import useSWR, { Fetcher } from 'swr'; | |
| import { useEffect, useMemo, useCallback, useState, MouseEvent } from "react"; | |
| import favicon from '../assets/favicon.ico'; | |
| import { | |
| ReactFlow, | |
| Controls, | |
| MarkerType, | |
| ReactFlowProvider, | |
| applyEdgeChanges, | |
| applyNodeChanges, | |
| useUpdateNodeInternals, | |
| type XYPosition, | |
| type Node, | |
| type Edge, | |
| type Connection, | |
| useReactFlow, | |
| MiniMap, | |
| } from '@xyflow/react'; | |
| // @ts-ignore | |
| import ArrowBack from '~icons/tabler/arrow-back.jsx'; | |
| // @ts-ignore | |
| import Backspace from '~icons/tabler/backspace.jsx'; | |
| // @ts-ignore | |
| import Atom from '~icons/tabler/atom.jsx'; | |
| import { syncedStore, getYjsDoc } from "@syncedstore/core"; | |
| import { WebsocketProvider } from "y-websocket"; | |
| import NodeWithParams from './nodes/NodeWithParams'; | |
| // import NodeWithTableView from './NodeWithTableView'; | |
| import EnvironmentSelector from './EnvironmentSelector'; | |
| import { LynxKiteState } from './LynxKiteState'; | |
| import '@xyflow/react/dist/style.css'; | |
| import { Workspace, WorkspaceNode } from "../apiTypes.ts"; | |
| import NodeSearch, { OpsOp, Catalog, Catalogs } from "./NodeSearch.tsx"; | |
| import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx"; | |
| import NodeWithImage from "./nodes/NodeWithImage.tsx"; | |
| import NodeWithTableView from "./nodes/NodeWithTableView.tsx"; | |
| export default function (props: any) { | |
| return ( | |
| <ReactFlowProvider> | |
| <LynxKiteFlow {...props} /> | |
| </ReactFlowProvider> | |
| ); | |
| } | |
| 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 }); | |
| useEffect(() => { | |
| const state = syncedStore({ workspace: {} as Workspace }); | |
| setState(state); | |
| const doc = getYjsDoc(state); | |
| const wsProvider = new WebsocketProvider(`ws://${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; | |
| console.log('update', JSON.parse(JSON.stringify(state.workspace))); | |
| 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]); | |
| 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' && !isNaN(ch.position.x) && !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) { | |
| console.log('edge change', ch); | |
| 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<Catalogs> = (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, | |
| }), []); | |
| const closeNodeSearch = useCallback(() => { | |
| setNodeSearchSettings(undefined); | |
| setSuppressSearchUntil(Date.now() + 200); | |
| }, [setNodeSearchSettings, setSuppressSearchUntil]); | |
| 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, setNodeSearchSettings, suppressSearchUntil]); | |
| const addNode = useCallback((meta: OpsOp) => { | |
| const node: Partial<WorkspaceNode> = { | |
| type: meta.type, | |
| data: { | |
| meta: meta, | |
| title: meta.name, | |
| params: Object.fromEntries( | |
| Object.values(meta.params).map((p) => [p.name, p.default])), | |
| }, | |
| }; | |
| const nss = nodeSearchSettings!; | |
| node.position = reactFlow.screenToFlowPosition({ x: nss.pos.x, y: nss.pos.y }); | |
| const title = meta.name; | |
| 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]); | |
| closeNodeSearch(); | |
| }, [nodeSearchSettings, state, reactFlow, setNodes]); | |
| 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!, | |
| }; | |
| console.log(JSON.stringify(edge)); | |
| state.workspace.edges!.push(edge); | |
| setEdges((oldEdges) => [...oldEdges, edge]); | |
| }, [state, setEdges]); | |
| const parentDir = path!.split('/').slice(0, -1).join('/'); | |
| return ( | |
| <div className="workspace"> | |
| <div className="top-bar bg-neutral"> | |
| <a className="logo" href=""><img src={favicon} /></a> | |
| <div className="ws-name"> | |
| {path} | |
| </div> | |
| <EnvironmentSelector | |
| options={Object.keys(catalog.data || {})} | |
| value={state.workspace.env!} | |
| onChange={(env) => { state.workspace.env = env; }} | |
| /> | |
| <div className="tools text-secondary"> | |
| <a href=""><Atom /></a> | |
| <a href=""><Backspace /></a> | |
| <a href={'/dir/' + parentDir}><ArrowBack /></a> | |
| </div> | |
| </div> | |
| <div style={{ height: "100%", width: '100vw' }}> | |
| <LynxKiteState.Provider value={state}> | |
| <ReactFlow | |
| nodes={nodes} | |
| edges={edges} | |
| nodeTypes={nodeTypes} fitView | |
| onNodesChange={onNodesChange} | |
| onEdgesChange={onEdgesChange} | |
| onPaneClick={toggleNodeSearch} | |
| onConnect={onConnect} | |
| proOptions={{ hideAttribution: true }} | |
| maxZoom={3} | |
| minZoom={0.3} | |
| defaultEdgeOptions={{ markerEnd: { type: MarkerType.Arrow } }} | |
| > | |
| <Controls /> | |
| <MiniMap /> | |
| {nodeSearchSettings && | |
| <NodeSearch pos={nodeSearchSettings.pos} boxes={nodeSearchSettings.boxes} onCancel={closeNodeSearch} onAdd={addNode} /> | |
| } | |
| </ReactFlow> | |
| </LynxKiteState.Provider> | |
| </div> | |
| </div> | |
| ); | |
| } | |