Spaces:
Running
Running
// The LynxKite workspace editor. | |
import { getYjsDoc, syncedStore } from "@syncedstore/core"; | |
import { | |
type Connection, | |
Controls, | |
type Edge, | |
MarkerType, | |
type Node, | |
ReactFlow, | |
ReactFlowProvider, | |
type XYPosition, | |
applyEdgeChanges, | |
applyNodeChanges, | |
useReactFlow, | |
useUpdateNodeInternals, | |
} from "@xyflow/react"; | |
import axios from "axios"; | |
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
import { Link } 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 UngroupIcon from "~icons/tabler/library-minus.jsx"; | |
// @ts-ignore | |
import GroupIcon from "~icons/tabler/library-plus.jsx"; | |
// @ts-ignore | |
import Restart from "~icons/tabler/rotate-clockwise.jsx"; | |
// @ts-ignore | |
import Close from "~icons/tabler/x.jsx"; | |
import Tooltip from "../Tooltip.tsx"; | |
import type { WorkspaceNode, Workspace as WorkspaceType } from "../apiTypes.ts"; | |
import favicon from "../assets/favicon.ico"; | |
import { usePath } from "../common.ts"; | |
// import NodeWithTableView from './NodeWithTableView'; | |
import EnvironmentSelector from "./EnvironmentSelector"; | |
import LynxKiteEdge from "./LynxKiteEdge.tsx"; | |
import { LynxKiteState } from "./LynxKiteState"; | |
import NodeSearch, { type OpsOp, type Catalog, type Catalogs } from "./NodeSearch.tsx"; | |
import NodeWithGraphCreationView from "./nodes/GraphCreationNode.tsx"; | |
import Group from "./nodes/Group.tsx"; | |
import NodeWithComment from "./nodes/NodeWithComment.tsx"; | |
import NodeWithImage from "./nodes/NodeWithImage.tsx"; | |
import NodeWithMolecule from "./nodes/NodeWithMolecule.tsx"; | |
import NodeWithParams from "./nodes/NodeWithParams"; | |
import NodeWithTableView from "./nodes/NodeWithTableView.tsx"; | |
import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx"; | |
export default function Workspace(props: any) { | |
return ( | |
<ReactFlowProvider> | |
<LynxKiteFlow {...props} /> | |
</ReactFlowProvider> | |
); | |
} | |
function LynxKiteFlow() { | |
const updateNodeInternals = useUpdateNodeInternals(); | |
const reactFlow = useReactFlow(); | |
const reactFlowContainer = useRef<HTMLDivElement>(null); | |
const [nodes, setNodes] = useState([] as Node[]); | |
const [edges, setEdges] = useState([] as Edge[]); | |
const path = usePath().replace(/^[/]edit[/]/, ""); | |
const shortPath = path! | |
.split("/") | |
.pop()! | |
.replace(/[.]lynxkite[.]json$/, ""); | |
const [state, setState] = useState({ workspace: {} as WorkspaceType }); | |
const [message, setMessage] = useState(null as string | null); | |
useEffect(() => { | |
const state = syncedStore({ workspace: {} as WorkspaceType }); | |
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.type !== "node_group" && n.dragHandle !== ".drag-handle") { | |
n.dragHandle = ".drag-handle"; | |
} | |
} | |
const nodes = reactFlow.getNodes(); | |
const selection = nodes.filter((n) => n.selected).map((n) => n.id); | |
const newNodes = state.workspace.nodes.map((n) => | |
selection.includes(n.id) ? { ...n, selected: true } : n, | |
); | |
setNodes([...newNodes] 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<Catalogs> = (resource: string, init?: RequestInit) => | |
fetch(resource, init).then((res) => res.json()); | |
const catalog = useSWR(`/api/catalog?workspace=${path}`, 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, | |
molecule: NodeWithMolecule, | |
comment: NodeWithComment, | |
node_group: Group, | |
}), | |
[], | |
); | |
const edgeTypes = useMemo( | |
() => ({ | |
default: LynxKiteEdge, | |
}), | |
[], | |
); | |
// Global keyboard shortcuts. | |
useEffect(() => { | |
const handleKeyDown = (event: KeyboardEvent) => { | |
// Show the node search dialog on "/". | |
if (nodeSearchSettings || isTypingInFormElement()) return; | |
if (event.key === "/") { | |
event.preventDefault(); | |
setNodeSearchSettings({ | |
pos: getBestPosition(), | |
boxes: catalog.data![state.workspace.env!], | |
}); | |
} else if (event.key === "r") { | |
event.preventDefault(); | |
executeWorkspace(); | |
} | |
}; | |
// 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 getBestPosition() { | |
const W = reactFlowContainer.current!.clientWidth; | |
const H = reactFlowContainer.current!.clientHeight; | |
const w = 200; | |
const h = 200; | |
const SPEED = 20; | |
const GAP = 50; | |
const pos = { x: 100, y: 100 }; | |
while (pos.y < H) { | |
// Find a position that is not occupied by a node. | |
const fpos = reactFlow.screenToFlowPosition(pos); | |
const occupied = state.workspace.nodes!.some((n) => { | |
const np = n.position; | |
return ( | |
np.x < fpos.x + w + GAP && | |
np.x + n.width + GAP > fpos.x && | |
np.y < fpos.y + h + GAP && | |
np.y + n.height + GAP > fpos.y | |
); | |
}); | |
if (!occupied) { | |
return pos; | |
} | |
// Move the position to the right and down until we find a free spot. | |
pos.x += SPEED; | |
if (pos.x + w > W) { | |
pos.x = 100; | |
pos.y += SPEED; | |
} | |
} | |
return { x: 100, y: 100 }; | |
} | |
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 findFreeId(prefix: string) { | |
let i = 1; | |
let id = `${prefix} ${i}`; | |
const used = new Set(state.workspace.nodes!.map((n) => n.id)); | |
while (used.has(id)) { | |
i += 1; | |
id = `${prefix} ${i}`; | |
} | |
return id; | |
} | |
function addNode(node: Partial<WorkspaceNode>) { | |
state.workspace.nodes!.push(node as WorkspaceNode); | |
setNodes([...nodes, node as WorkspaceNode]); | |
} | |
function nodeFromMeta(meta: OpsOp): Partial<WorkspaceNode> { | |
const node: Partial<WorkspaceNode> = { | |
type: meta.type, | |
data: { | |
meta: { value: meta }, | |
title: meta.name, | |
params: Object.fromEntries(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, | |
}); | |
node.id = findFreeId(node.data!.title); | |
addNode(node); | |
closeNodeSearch(); | |
}, | |
[nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch], | |
); | |
const onConnect = useCallback( | |
(connection: Connection) => { | |
setSuppressSearchUntil(Date.now() + 200); | |
const edge = { | |
id: `${connection.source} ${connection.sourceHandle} ${connection.target} ${connection.targetHandle}`, | |
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<HTMLDivElement>) { | |
e.stopPropagation(); | |
e.preventDefault(); | |
} | |
async function onDrop(e: React.DragEvent<HTMLDivElement>) { | |
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.id = findFreeId(node.data!.title); | |
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); | |
} catch (error) { | |
setMessage("File upload failed."); | |
} | |
} | |
async function executeWorkspace() { | |
const response = await axios.post(`/api/execute_workspace?name=${path}`); | |
if (response.status !== 200) { | |
setMessage("Workspace execution failed."); | |
} | |
} | |
function deleteSelection() { | |
const selectedNodes = nodes.filter((n) => n.selected); | |
const selectedEdges = edges.filter((e) => e.selected); | |
reactFlow.deleteElements({ nodes: selectedNodes, edges: selectedEdges }); | |
} | |
function groupSelection() { | |
const selectedNodes = nodes.filter((n) => n.selected && !n.parentId); | |
const groupNode = { | |
id: findFreeId("Group"), | |
type: "node_group", | |
position: { x: 0, y: 0 }, | |
width: 0, | |
height: 0, | |
data: { title: "Group", params: {} }, | |
}; | |
let top = Number.POSITIVE_INFINITY; | |
let left = Number.POSITIVE_INFINITY; | |
let bottom = Number.NEGATIVE_INFINITY; | |
let right = Number.NEGATIVE_INFINITY; | |
const PAD = 10; | |
for (const node of selectedNodes) { | |
if (node.position.y - PAD < top) top = node.position.y - PAD; | |
if (node.position.x - PAD < left) left = node.position.x - PAD; | |
if (node.position.y + PAD + node.height! > bottom) | |
bottom = node.position.y + PAD + node.height!; | |
if (node.position.x + PAD + node.width! > right) right = node.position.x + PAD + node.width!; | |
} | |
groupNode.position = { | |
x: left, | |
y: top, | |
}; | |
groupNode.width = right - left; | |
groupNode.height = bottom - top; | |
setNodes([ | |
{ ...(groupNode as WorkspaceNode), selected: true }, | |
...nodes.map((n) => | |
n.selected | |
? { | |
...n, | |
position: { x: n.position.x - left, y: n.position.y - top }, | |
parentId: groupNode.id, | |
extent: "parent" as const, | |
selected: false, | |
} | |
: n, | |
), | |
]); | |
getYjsDoc(state).transact(() => { | |
state.workspace.nodes!.unshift(groupNode as WorkspaceNode); | |
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id)); | |
for (const node of state.workspace.nodes!) { | |
if (selectedNodeIds.has(node.id)) { | |
node.position.x -= left; | |
node.position.y -= top; | |
node.parentId = groupNode.id; | |
node.extent = "parent"; | |
node.selected = false; | |
} | |
} | |
}); | |
} | |
function ungroupSelection() { | |
const groups = Object.fromEntries( | |
nodes | |
.filter((n) => n.selected && n.type === "node_group" && !n.parentId) | |
.map((n) => [n.id, n]), | |
); | |
setNodes( | |
nodes | |
.filter((n) => !groups[n.id]) | |
.map((n) => { | |
const g = groups[n.parentId!]; | |
if (!g) return n; | |
return { | |
...n, | |
position: { x: n.position.x + g.position.x, y: n.position.y + g.position.y }, | |
parentId: undefined, | |
extent: undefined, | |
selected: true, | |
}; | |
}), | |
); | |
getYjsDoc(state).transact(() => { | |
const wnodes = state.workspace.nodes!; | |
for (const node of state.workspace.nodes!) { | |
const g = groups[node.parentId as string]; | |
if (!g) continue; | |
node.position.x += g.position.x; | |
node.position.y += g.position.y; | |
node.parentId = undefined; | |
node.extent = undefined; | |
} | |
for (const groupId in groups) { | |
const groupIdx = wnodes.findIndex((n) => n.id === groupId); | |
wnodes.splice(groupIdx, 1); | |
} | |
}); | |
} | |
const areMultipleNodesSelected = nodes.filter((n) => n.selected).length > 1; | |
const isAnyGroupSelected = nodes.some((n) => n.selected && n.type === "node_group"); | |
return ( | |
<div className="workspace"> | |
<div className="top-bar bg-neutral"> | |
<Link className="logo" to="/"> | |
<img alt="" src={favicon} /> | |
</Link> | |
<div className="ws-name">{shortPath}</div> | |
<title>{shortPath}</title> | |
<EnvironmentSelector | |
options={Object.keys(catalog.data || {})} | |
value={state.workspace.env!} | |
onChange={(env) => { | |
state.workspace.env = env; | |
}} | |
/> | |
<div className="tools text-secondary"> | |
{areMultipleNodesSelected && ( | |
<Tooltip doc="Group selected nodes"> | |
<button className="btn btn-link" onClick={groupSelection}> | |
<GroupIcon /> | |
</button> | |
</Tooltip> | |
)} | |
{isAnyGroupSelected && ( | |
<Tooltip doc="Ungroup selected nodes"> | |
<button className="btn btn-link" onClick={ungroupSelection}> | |
<UngroupIcon /> | |
</button> | |
</Tooltip> | |
)} | |
<Tooltip doc="Delete selected nodes and edges"> | |
<button className="btn btn-link" onClick={deleteSelection}> | |
<Backspace /> | |
</button> | |
</Tooltip> | |
<Tooltip doc="Re-run the workspace"> | |
<button className="btn btn-link" onClick={executeWorkspace}> | |
<Restart /> | |
</button> | |
</Tooltip> | |
<Tooltip doc="Close workspace"> | |
<Link className="btn btn-link" to={`/dir/${parentDir}`} aria-label="close"> | |
<Close /> | |
</Link> | |
</Tooltip> | |
</div> | |
</div> | |
<div | |
style={{ height: "100%", width: "100vw" }} | |
onDragOver={onDragOver} | |
onDrop={onDrop} | |
ref={reactFlowContainer} | |
> | |
<LynxKiteState.Provider value={state}> | |
<ReactFlow | |
nodes={nodes} | |
edges={edges} | |
nodeTypes={nodeTypes} | |
edgeTypes={edgeTypes} | |
fitView | |
onNodesChange={onNodesChange} | |
onEdgesChange={onEdgesChange} | |
onPaneClick={toggleNodeSearch} | |
onConnect={onConnect} | |
proOptions={{ hideAttribution: true }} | |
maxZoom={10} | |
minZoom={0.2} | |
zoomOnScroll={false} | |
panOnScroll={true} | |
panOnDrag={false} | |
selectionOnDrag={true} | |
panOnScrollSpeed={1} | |
preventScrolling={false} | |
defaultEdgeOptions={{ | |
markerEnd: { | |
type: MarkerType.ArrowClosed, | |
color: "black", | |
width: 15, | |
height: 15, | |
}, | |
style: { | |
strokeWidth: 2, | |
stroke: "black", | |
}, | |
}} | |
fitViewOptions={{ maxZoom: 1 }} | |
> | |
<Controls /> | |
{nodeSearchSettings && ( | |
<NodeSearch | |
pos={nodeSearchSettings.pos} | |
boxes={nodeSearchSettings.boxes} | |
onCancel={closeNodeSearch} | |
onAdd={addNodeFromSearch} | |
/> | |
)} | |
</ReactFlow> | |
</LynxKiteState.Provider> | |
{message && ( | |
<div className="workspace-message"> | |
<span className="close" onClick={() => setMessage(null)}> | |
<Close /> | |
</span> | |
{message} | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |