darabos's picture
Make metadata work before backend response.
b44f5a8
// 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>
);
}